Laravel, security and the paradox of choice
Song: "Don't Explain", Dexter Gordon
I've been working with Laravel recently. It's good, probably the best tool for making webapps in PHP right now, for anyone still left doing that. But like any framework, it isn't perfect. It has some very PHP-ish flaws, like misusing established jargon. But the biggest PHP-ish Laravel sin is that there are a bajillion different ways to do every little thing.
Having lots of good choices isn't always better. If I ask you to choose between 10 different flavors of ice cream you like, it may be hard for you to pick one. Do you go with rocky road, or pistachio, or chocolate? They're all good choices. You think about each one, and are happy, then think about the 9 flavors you don't get to have by choosing that one, and are less happy.
If I give you two choices, one you like, and one you hate, you will probably be happier with your decision than if you had 10 good choices to sift through. You can feel confident that you picked the right one. There's no chance for regret or second-guessing. That's the paradox of choice. People can end up being less happy if they have too many good options.
In an engineering context, it's not exactly like picking rocky road and kinda wishing you picked pistachio, but too many options still causes mental overhead. This pops up a lot in Laravel. Let's say you want to get the current user. You can:
- call
auth()->user.auth()is a helper method that's available everywhere in Laravel. - call
request()->user.request()is another global helper method. - call
Auth::user().Authis a facade that's also available everywhere. - have the user passed in by automatic dependency injection, possibly with the help of the
[CurrentUser]annotation or route model binding - pass the request through automatic dependency injection, then get the user from that
There are probably others! That's true of basically everything in Laravel. The documentation will give you 5 different ways to do the same thing and say it's up to you to choose. Maybe it's not a big deal, but who does that help, exactly?
If there's only one way to do a thing, everybody uses it. In Django, to get the current user, you do request.user. In Yii, you do Yii::$app->user. There's no need to think about it when reading or writing Django or Yii code, and probably most other frameworks.
Laravel forces us out of a coding flow state to make trivial choices all over the place. You have to sit down and decide which way you're going to do it instead of just doing it. It's not really a creative choice, or one that makes the software more powerful. It's another thing to think about that doesn't create value for anybody.
It's worse with multiple developers working on a project, especially with AI helpers in the mix. It's bad for maintainability to have everybody doing it their own way, based on their current mood or what they're used to, rather than a good engineering reason.
Most of the time, Laravel's paradox of choice isn't a big deal. It's just a vector for sloppiness, not a fatal flaw. Sometimes the team needs to establish the official way of doing things, though thanks to the Law of Triviality, trying to hash out the One True Way can be an annoying waste of time.
I wonder how many thousands of developer-hours have been wasted worldwide deciding between "PUT" and "PATCH". I still remember a meeting from 13 years ago where we spent a good half hour debating that issue. It was an internal API, and couldn't have mattered less. People in general, and nerds specifically, tend to get sucked into trivial bikeshedding debates. If the two choices were "PUT" and a bit of eldritch speech that summons the Ancient Gods to devour the world, we'd all be fine with using "PUT", I'm sure. It would've been a short meeting.
Not knowing what you're doing versus not even trying
It's different when security is involved. What's happening and why needs to be crystal clear. I think it's worth being dogmatic about how security-related code is written.
The OWASP Top 10 is a list of the most common types of security problems on the web. It's easy to think of security and envision a zero-day attack on the Linux kernel or some daring social engineering hack, but the vast majority of attacks either fall under the heading of "Not Knowing What We're Doing", or "We're Not Even Trying". Anybody who creates software for the web should be hyper-aware of the OWASP Top 10, if only to understand how prosaic most of the issues are.
The top two issues in the 2025 list were Broken Access Control and Security Misconfiguration. Those two are always near the top, and both of them can be affected by having too many choices.
In order to have a Security Misconfiguration, you have to have a configuration to misconfigure, right? Somebody chose an insecure configuration, or the software wasn't secure out of the box. More moving parts increases the odds of that happening. A certain amount of Security Misconfiguration issues could be eliminated by having fewer things you can do wrong.
Laravel ships with pretty reasonable security defaults, though it's easy to overindex on the wrong things when looking at a list of features. Any good framework should protect you against XSS, CSRF, injection, and mass assignment, which I'd classify as "Not Even Trying" problems at this point. Additional browser security features, like Content-Security-Policy and Same Site cookies, have made it harder to screw things up, even if you misconfigure things, or the framework fails to prevent a problem. Anyone who's actually trying should be pretty well protected against those issues.
The #1 issue, Broken Access Control, which was also #1 in the previous rankings in 2021, is squarely in the Not Knowing What We're Doing category. This is a place where disciplined engineering practices can make a big difference, and where Laravel's "do it however you want, yolo" approach to everything doesn't just trigger annoying bikeshedding convos but actual security incidents.
A maze of twisty passages, all alike.
Authorization is really easy to screw up in the best of conditions. Nobody wakes up and thinks, "I'm gonna do auth wrong today." We try, and we do it wrong. Our code is reviewed by some other person before it's shipped, and they miss it too. There's a reason why it's the #1 security issue -- it's error prone.
Laravel lets its users down by giving them too many auth-related choices. To illustrate, I'm going to try to enumerate all the ways you can do authorization checks in the latest version of Laravel (13.x). It's a staggeringly long list, and I'm probably missing some. Official documentation here, if you want to play along at home.
You can:
- do it in raw PHP without using the Laravel authZ system at all. The official documentation suggests this.
- define an access control rule on the fly and then use it in the middle of a controller
- do it on the fly in the routes file
- define model-level policies in the application service provider's
boot()method (Laravel's junk drawer, ugh) - manually register Policy classes in non-standard locations through the
boot()method as well - manually register a policy via a different syntax in
boot()called a "class callback array" - create a Policy class (which is auto-discovered) and check them against the current user/object with the
Gate::facade - use the
->can()method on the user object to check policies - check a policy against the model's class (rather than specific objects) with various methods
- override a Policy with a policy filter and
Gate::before(), which supersedes the normal policy checks. - also override policy checks with
Gate::after() - apply policies to controller classes via PHP attributes
- apply policies through the
canmiddleware applied to a single route, by munging the normal arguments tocan()into a single string (ugh) - apply the
canmiddleware on a group of routes - check permissions in the
authorize()method of a Form Request - show/hide content in the view layer based on policies using the
@candirective - if you are using Laravel Inertia (their framework for single-page webapps), you can also define security policies in the
HandleInertiaRequestsmiddleware (which uses different syntax than other auth code in Laravel)
If you have both HTML and JSON API endpoints, that can mean two sets of whatever method you choose, with added requirements to exclude database fields you'd never expose through HTML.
Sheesh. Imagine doing a security audit on a Laravel application. You'd have to check every one of those possibilities to understand/document how the system does auth, then figure out if it's working as intended.
As with Laravel's other "choose your own adventure" options, it appears that all the above methods are using the same basic code. The framework hasn't dramatically increased the complexity/attack surface of their own code, but they have yours. How can we understand whether authorization is being applied the way it's supposed to when there are so many possible ways to apply the rules?
Remediations & recommendations
I'm not a security specialist. For security-specific best practices, OWASP is an amazing resource. These recommendations come from my experience as a longtime software engineer, and how to write clear, maintainable, observable code that does what it's supposed to do.
Laravel has a nice event system. It fires off an event called GateEvaluated every time that an authorization policy is checked. It's pretty easy to log every auth check performed by Laravel, but it's not going to be all that helpful without some curation.
For instance, frontend code can use the @auth directive to decide whether to display an edit link on a piece of content. If you're the owner, it displays the link, otherwise it doesn't. The vast majority of the time, the edit link shouldn't display and that check is going to fail. It doesn't mean that someone is trying to hack the system. So failed auth checks could have a lot of noise.
If that's the case, it should be a forcing function to make auth checks more granular. There should be separate policies for can edit (used to gate access to the edit page, or saving edits) and can view edit link (prevent showing the link to the edit page). can view edit link failures are not a result of user misbehavior, and shouldn't be logged.
All genuine auth check failures should be logged and monitored. But failure logs can't tell you about the dog that didn't bark -- an auth check that should have failed, but passed instead, or a resource that is missing an auth check entirely. A log of successful auth checks would be valuable for incident response after something bad has happened, but you can't really use it to proactively find problems.
The question of "should person A be allowed to do action B to thing C in scenario D?" is at least partially a business logic question. Somebody or something has to know how the business should work to recognize the code's not working right. That requires a certain amount of manual auditing of the codebase. If authorization rules are defined and checked in a standard way, that's a whole lot easier to do.
OWASP advises having authorization logic on every request, and recommends use of middleware to provide broad spectrum protection and consistency. With Laravel, middleware makes sense for high-level checks (non-logged in users shouldn't be blocked from URLs that are only for logged-in users; non-admin users shouldn't be able to visit any /admin/* pages). Because middleware can be applied in so many different ways, I would strongly recommend picking one method and sticking with it from the beginning.
For finer-grained controls I would rather see explicit checks done in controllers rather than implicitly through Laravel's middleware syntax. Although it might be tedious to have auth code in every controller endpoint, that makes it clear that the work has been done. If I'm doing a code review and I see a new endpoint without any explicit auth checks, that's an obvious red flag.
Out of the box, Laravel doesn't have any notion of user roles, which seems like a big omission. There are packages that add RBAC capability to Laravel. Spatie's laravel-permission package is almost certainly preferable to rolling your own.
Start with policy classes
Use Attribute-Based Access Control for more complex scenarios, encapsulated in Laravel's Policy classes. Consistent use of Policy classes will ensure that all authorization rules are defined in a predictable location. Use them from the very beginning of a project. And test the heck out of them.
As far as checks against those policies, explicitly using the Gate:: functions everywhere makes findability easier. Finding every auth check being done in the system shouldn't be some kind of treasure hunt. The user()->can() syntax is convenient but can't apply to all auth situations.
Findability of permission definitions is also important. Can I search for the name of the permission and find everywhere in the code using it? Do permissions have meaningful names? Can I easily see which role(s) the permissions are assigned to? Can I see which users have that role, and maybe some clue why they're in that role? Are the permissions granular enough, and have they been set up to deny by default?
Laravel FormRequests are the framework's one nod to more sophisticated security. They offer a mechanism to combine authorization and validation in one object. I'm not a huge fan of them because data validation should be a model level concern, not a controller level one, and there's no good reason why authorization and data validation should be tightly coupled to HTML forms. More on that next week.
While FormRequests are odd from an architecture standpoint, consistent use of them is certainly better than a scattershot approach to auth. Keep things simple and consistent wherever possible. If you are using FormRequests, use them everywhere.
TL;DR
Make it as easy as possible for developers to do the right thing, and understand whether the code is working like it should, without thinking too hard about it. Awareness of security best practices is important, but it doesn't matter if the execution is wrong. Writing security related code in a consistent, maintainable way is its own challenge. Don't let Laravel's cornucopia of choices make that harder.
