Web development teams often set out to build rich business applications, yet fall back on a (familiar) data-driven approach: business logic ends up buried in the UI and database scripts, permissions are managed globally, and Php code looks like it’s still 2015. Initially this may seem like the easiest and quickest way to get things up and running – but when business rules are diffused through a large amount of UI and persistence code, they become hard to see, to reason about, and to evolve.
The refactoring walk-through in a recent Medium article aiming to enhance authorisation for a holiday booking app illustrates this mindset. The solution presented is clever on the surface, but skips over the fundamentals: clean code, separation of concerns, and architectural boundaries. More critically, it treats authorisation entirely as “access to screens,” ignoring that rules often depend on object state, or are intrinsic to an object’s behaviour.
This article is a constructive critique. Across five levels, I’ll unpack where the original approach breaks down, from code quality to architectural design, and show how Symfony’s built-in tools with and without domain-driven design offer a cleaner, more maintainable alternative that aligns authorisation with business logic.
Nesting nightmare
Let’s zoom in on the microscopic code quality of their final example. Here’s the class in question:
class GroupPermissionDecorator implements PermissionProviderInterface {
public function __construct( private PermissionProviderInterface $wrappedProvider ) {}
public function getPermissions( AccessUser $user ): Collection {
$permissions = $this->wrappedProvider->getPermissions($user);
if( $user->isInternalUser() ) {
foreach( $user->getInternalUser()->getGroups() as $group ) {
foreach( $group->getPermissions() as $permission ) {
if ( ! $permissions->contains($permission) ) {
$permissions->add($permission);
}
}
}
}
return $permissions;
}
}
This is a nesting jungle: getPermissions calls getPermissions to check if a permission contains a permission before adding a permission. Feels like my brain’s in a blender.
The method name doesn’t help either. What permissions are we talking about? And how is this different from the user group’s own getPermissions()? Same name, different logic, different parameters — confusing. And who exactly are “internal” and “non-internal” (external?) users? Staff? Customers? A little clarity wouldn’t hurt.
Also, please retire the “get” prefix. It’s not 2000 anymore and this isn’t Java. Use nouns for accessors and strong verbs for actions. With property hooks and asymmetric visibility in PHP, you don’t even need boilerplate getters anymore.
Finally, while I’m all in favour of reducing primitive obsession, I don’t see why this method needs to return a Doctrine collection object instead of an array. Doctrine collections are wrappers (lazy-)loading ORM-managed associations but don’t add much to ordinary arrays.
Here’s a comprehensible version:
public function allPermissionsForUser( AccessUser $user ): array {
$permissions = $this->wrappedProvider->allPermissionsForUser( $user );
if ( $user->isStaffMember() ) {
$permissions = array_reduce( $user->groups, fn($carry, PermissionProviderInterface $group) => [ ...$carry, ...$group->permissions ], $permissions );
}
return array_unique( [...$permissions ] );
}
Decorator Misuse
As you can see in the example above, the entire Medium article is about using the decorator pattern to wrap different levels of permissions, and praises this as an architectural win. But this implementation misses the mark. A decorator is meant to “attach additional responsibilities to an object dynamically” (Gamma, E. 1995) letting you extend behaviour without modifying the original class or building inheritance pyramids.
However, in this case the decorator does not add any behaviour to a permission provider. In fact, the permission provider doesn’t exhibit any behaviour at all: It merely represents a data structure for permissions and a getter. That’s not even a proper object, so applying an OOP pattern here makes little sense. What we get instead is a stack of conditionals that combines permissions in a tangled way. You saw the nesting mess earlier. Ironically, their original PermissionProvider with separate methods for staff and customers was clearer.
If you wish to implement distinct authorisation logic for different user types, the article’s own language hints at a better fit: the strategy pattern. That’s what Symfony’s AccessDecisionManager uses too.
CQS violations
Up to this point, everything’s been about gathering permissions for users, groups, or roles. Then suddenly, this new class enters the scene:
class PermissionService {
public function __construct( private PermissionProviderInterface $permissionProvider ) {}
public function resolvePermissions(AccessUser $user): PermissionSet {
return new PermissionSet(
$this->permissionProvider->getPermissions($user)
);
}
}
“Resolving” a conflict or a problem is fundamentally different from “getting” information about permissions. The former is an action (or command), the latter is a query. This distinction is important as the command-query separation principle states that a method should perform either an action (a command) or return data (a query), never both. In other words, asking a question should not change the answer. Why? Because queries should be safe, predictable, and side-effect-free. Mixing the two can lead to accidental state modifications and cause Heisenbugs1 🐛🙈🐵
The method name resolvePermissions() deceptively sounds like a command — go figure out if this person can perform that action — and as such should only ever return void. But on closer inspection it just wraps the “all permissions for user” query and returns a PermissionSet object. Two options here:
- Drop the ambiguity and rename the method to something honest, like
allPermissionsForUser(). That’s the same name as suggested forPermissionsProviderand that’s because this service adds nothing to the original query method. - Scrap the service entirely and construct the
PermissionSetwhere you need it, inPermissionsProvider.
Or, if you really want to “resolve” something, make it a true command: Pass in the user and the action as parameters, throw an exception if the user isn’t authorised, and return void. This is precisely how Symfony’s Voter component works, though it returns a boolean to allow developers to use it in route attributes (still, dear Symfony devs, a true command would have been cleaner). If you find the naming confusing (I certainly do), think of the voter as a passport and it’s “vote on attribute” method as “checkVisa”2.
By the way, the AccessDecisionManager mentioned earlier integrates seamlessly with the Voter component. Which begs the question: Why reinvent the wheel with a “Privilege Decorator” when the framework already provides robust, battle-tested tools for this use case?
Chatty over-exposure
By now you might be wondering: why return all user permissions at once? If someone visits the user management board, do we really need to know whether they can cancel bookings, reimburse payments, walk the dog, or scratch their nose? Or, do we just need to decide whether to show them the board or a friendly 404?
Leaking all permissions upfront violates a core principle of software design: serve only what’s needed. Whether you call it the Law of Demeter or the Principle of Least Privilege, the idea is the same: Don’t expose internals unless absolutely necessary. If attackers get hold of a permission set through accidental exposure in logs, debug tools, or an insecure API, they gain a map of what the user can and cannot do. That’s gold for privilege escalation. But even for legitimate users, exposing all permissions can leak sensitive business logic.
That’s why Symfony’s security components never return the authorised role and in fact don’t manage permissions in the sense of maintaining a centralised permission registry at all. Instead, with Symfony you delegate authorisation logic to a Voter, where you define your own permission rules in voteOnAttribute().
From a domain-driven design (DDD) perspective, this is also a boundary breach. Permissions belong to a supporting subdomain. Broadcasting them invites other parts of the system to make decisions they shouldn’t own. Before long, you’ve got permission checks scattered across the codebase, tight coupling, and inconsistent enforcement. And then invariants get bypassed and regressions sneak in.
Authorisation where it belongs
Authorisation isn’t just about access to screens: It’s about enforcing business rules and answers questions like who can publish an articles, cancel a booking, or authorise a payment. These are domain-specific decisions, not global ones. Each context should own its logic: For instance, the booking domain needs to know who can cancel a booking, but it doesn’t need to care who’s allowed to create trip offers or manage users. Keeping authorisation local to its domain preserves encapsulation, reduces coupling, and makes business rules easier to reason about and evolve.
Authorisation by ownership
Let’s take booking cancellation as a straightforward example: To determine whether someone can cancel a booking, you only need one thing: confirmation that they made the booking. A well-designed Booking aggregate will retain a reference to the customer, whether as a value object, an association, or an identifier. You compare user IDs or email addresses and postcode, or whatever uniquely identifies the person. In an application service, the logic might look like this:
if ( $booking->customer->ID->equals( $customer->ID ) ) {
throw new AccessDenied();
}
No need for fancy access control, permission sets, or role checks. The domain itself knows enough to make the decision.
Of course, when there’s no direct relationship between the user and the object in question, things get trickier. That’s when you need to reach beyond the aggregate and possibly involve external authorisation logic.
Contextual authorisation by a voter
Consider an accountancy department with the following rule: Payments of €5,000 or more must be authorised by a senior accountant, and anything over €10,000 requires sign-off from a CEO. These rules are based on current company policy. In the future the required roles might change and thresholds might be adjusted or new limits added..
You might be tempted to encode this logic into a permission system adding "authorise_payments_5000" and "authorise_payments_10000" to it. But every time the thresholds change or new ones are added, you’d need to update the permission list and (re)assign them to roles alongside all the other permissions for assigning accounts, recognising revenue, filing IRS reports, and whatever else accountants do. That’s hundreds, maybe thousands, of permissions for a big company. It’s clearly unsustainable, especially when only one context, the payment domain, needs to know about them.
Symfony’s Voter component offers a cleaner solution. It separates two concerns that should never be mixed: Determining whether a user has a role (handled by the AccessDecisionManager) and deciding whether that role can perform a specific action. Here’s how you might implement it:
class AuthorisePaymentForPayable extends Voter {
public function __construct(
private AccessDecisionManagerInterface $accessDecisionManager,
) {}
protected function supports( string $attribute, mixed $subject ): bool {
return $subject instanceof Payable
&& PayablePermission::tryFrom( $attribute ) instanceof PayablePermission;
}
protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null ): bool {
/** @var Payable $payable */
$payable = $subject;
$requiredRole = match(true) {
$payable->total->amount >= 10000 => 'ROLE_BOSS_OF_IT_ALL',
$payable->total->amount >= 5000 => 'ROLE_SENIOR_ACCOUNTANT',
default => 'ROLE_ACCOUNTANT'
};
// Internally uses a RoleVoter
return $this->accessDecisionManager->decide( $token, [ $requiredRole ] );
}
}
Now, if the CEO wants to change the thresholds, you only need to update this voter instead of a sprawling permission matrix. In a hexagonal architecture, this voter would live in the application layer of the Payable3 domain and be used by a PaymentAuthorisation service to grant permission, update the Payable’s state to “authorised,” and dispatch a corresponding domain event.
That said, this implementation still has a flaw: the authorisation logic is part of the Payable domain, and ultimately an intrinsic invariant of the Payable aggregate. It can and should live inside the aggregate itself, as the next example will demonstrate.
Authorisation with a rich domain model
In real-world scenarios, single approvals aren’t enough to prevent mistakes or fraud. At a mid-sized business I consulted for, the actual rules looked more like this:
- Payments of 5.000 Euros or more must be authorised by both an accountant and a senior accountant
- Payments over 10.000 Euros require approval from a senior accountant and a CEO.
- These thresholds and required roles may change over time.
This means authorisation isn’t a simple thumbs-up anymore, it’s a process. Each approval must be recorded. Only once all required authorisations are in place can the payment be released. In domain-driven design, this is called an invariant: A rule that must always hold true. The Payable aggregate is responsible for enforcing it, ensuring it never transitions into an invalid state.
Authorisation, in this case, isn’t just a technical concern but an integral part of the business logic. And that’s exactly where it now lives:
class Payable {
private(set) array $authorisations;
private(set) PayableState $state; // received, authorised, paid, rejected...
private(set) AuthStrategy $authStrategy; // level 1, 2, 2
public function __construct(
private int $total, // small amount
/** ... other params **/
) {
$this->state = new PayableReceived();
$this->authStrategy = match(true) {
$this->total >= 1000000 => new Level3AuthStrategy(),
$this->total >= 500000 => new Level2AuthStrategy(),
default => new Level1AuthStrategy()
};
}
private function addPaymentAuthorisation( StaffMember $user ):void {
// Do we already have an authorisation by someone with this role?
$authorisation = array_find( $this->authorisations, fn( Authorisation $auth ) => $auth->role === $user->role ) );
if( $authorisation ) {
throw new AlreadyAuthorised("Payment has already been authorised by a {$user->role}. ");
}
$this->authorisations[] = new Authorisation( by: $user->ID, role: $user->role );
}
public function authorise( AccessDecisionManagerInterface $accessDecisionManager, TokenInterface $token ): void {
// Can the given user (in token) authorise such a payment at all?
$isAllowed = $this->accessDecisionManager->decide( $token, $this->strategy->roles ) );
if( false === $isAllowed ) {
throw new PermissionDenied();
}
$this->addAuthorisation( $token->getUser() );
}
public function release(): void {
foreach( $this->strategy->roles as $role ) {
$authorisation = array_find( $payable->authorisations, fn(Authorisation $auth) => $auth->role === $role );
if( ! $authorisation ) {
throw new PermissionDenied( "Payment must be authorised by a $role. ");
}
}
$this->state = new PayableFullyAuthorised();
}
}
The beauty of this “rich” domain model is that Payable enforces its own rules. It knows what’s required and applies those rules through its authorise() and release() methods. There’s no way for any other code to bypass its invariants – ”a hallmark of a highly encapsulated domain model” (Khorikov, V. 2020). In other words, our domain model is complete. Domain logic fragmentation on the other hand is when the domain logic resides in layers other than the domain layer, for instance the app layer, where the Voter in the previous section lived.
What completes our domain model here is that we pass the AccessDecisionManager as a domain service to authorise(), using it to check whether the current user is allowed to sign off on a payment of the given amount. If so, we record the authorisation — but only if it hasn’t already been granted by someone with the same role.
Now, purists might object to passing a Token into the domain layer. After all, it’s a framework detail, not a domain concept. But moving the access decision logic back into a voter would require exposing the authorisation strategy, a clear violation of the Law of Demeter, which states that a unit should only talk to its immediate friends. So we’re making a trade-off here: domain purity vs. encapsulated completeness. Both approaches are valid4.
Here’s what the level 3 authorisation strategy for payments over €10,000 might look like:
class Level3AuthStrategy implements AuthStrategy {
public array $roles { get => [ 'ROLE_BOSS_OF_IT_ALL', 'ROLE_SENIOR_ACCOUNTANT' ]; }
}
All right, that’s underwhelming. But that’s precisely what interfaces should be. The strategy pattern is all about encapsulating variation and right now in this simplified example variation is small. But the pattern gives you room to grow:5 In our example, the release() method bases its decision solely on roles it retrieves from the interface, when in reality it needs to consider more factors like e.g. signature dates.
Last but not least, here’s the stripped-down version of our voter:
class AuthorisePaymentForPayable extends Voter {
// ... constructor and supports() method as before
protected function voteOnAttribute( string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null ): bool {
/** @var Payable $payable */
$payable = $subject;
try {
$payable->authorise( $this->accessDecisionManager, $token );
} catch( PermissionDenied|AlreadyAuthorised $e ) {
return false;
}
return true;
}
}
This voter delegates the decision entirely to the domain, where the rules live. It’s lean, expressive, and respects the boundaries of responsibility.
Summary
Over five sections, we traced a familiar trajectory: from low-level code issues and misleading naming, through misused patterns and architectural drift, to the deeper problem of treating authorisation as a global, UI-driven concern. The original solution may work, but it lacks clarity, cohesion, and does not connect well with business logic it may need to enforce. Using Symfony’s built-in tools we could offer a cleaner alternative and explored how how domain-driven design can integrate complex, rules-based authorisation.
I know this style of architecture probably feels unfamiliar if you’ve worked with data-driven and procedural approaches a lot and that you’re probably still sceptical. But I hope I’ve also shown how much clarity and flexibility it can bring. Please do give it a try ☺️. And, if you liked this article and your team feels buried in complexity, I can also help you move from procedural, data-driven approaches to business-aligned software [/shamelessPlugOff]
Finally, time for rewards! Thanks for staying tuned through this long article. Now it’s time to enjoy BOSS OF IT ALL, the best office comedy ever made (well, at least the trailer):