Laravel ships with strong defaults, and defaults are not a security policy. The Laravel security best practices that decide whether a production app holds up in 2026 are the ones the framework cannot apply for you. Authorization on every record, strict mass assignment, debug output switched off, hardened session cookies, audited dependencies, and file uploads you validate by content.

Most Laravel incidents are not clever. They are a forgotten APP_DEBUG=true on a staging box that quietly became production, or a controller that validates input perfectly and never checks who owns the row it is about to update.

You already know Blade escapes output and the query builder binds parameters. That layer is handled. What follows is the layer above it, the part you own, written as a checklist you can run against a real codebase before the next deploy. We will cover the framework's boundary, production configuration, authorization and authentication, input handling, a pre-deploy list, and how to review AI generated Laravel for the gaps it tends to leave behind.

Key TakeawaysLaravel blocks SQL injection, XSS, and CSRF by default, but authorization, mass assignment, and file upload rules are entirely yours to write.APP_DEBUG=false and APP_ENV=production are the highest value settings on the list, because debug pages have been part of real remote code execution chains in the Laravel ecosystem.Every write action needs a Policy check, not just a FormRequest. Validation confirms the shape of the data and never confirms ownership.Run composer audit in CI. A current Laravel app with one outdated transitive package is still an exposed app.AI generated Laravel usually gets validation right and skips authorization, so review generated controllers for missing Policy calls before merging.

Laravel security best practices start where the defaults end

Laravel closes several OWASP Top 10 categories before you write a line of application code. Knowing exactly where that coverage stops is the difference between a secure Laravel app and one that only feels secure.

Risk

Handled by Laravel

Still your job

SQL injection

Eloquent and the query builder bind parameters

Anything inside DB::raw(), whereRaw(), or orderByRaw()

XSS

{{ }} escapes output

Every {!!!!}, plus HTML you store and render back

CSRF

@csrf and the CSRF middleware on web routes

Routes you excluded, and stateless API endpoints

Password storage

Bcrypt hashing through the Hash facade

Login throttling, 2FA, and session invalidation

Broken access control

Nothing

Policies, Gates, scoped bindings, and tenant filters

Mass assignment

Guarded by default

Whatever you add to $fillable, and any unguard() call

Read the last two rows again. Broken access control is the category the framework cannot help with, because only your domain knows that an invoice belongs to a team and a ticket belongs to a customer. It also produces the quietest bugs, the ones that pass every test and hand data to the wrong tenant.

Curious how a Laravel-native assistant handles that layer while it scaffolds a feature? Try StackMivo Now and read what it generates for a resource that needs ownership checks.

Lock down the production environment first

Environment configuration holds the cheapest wins on this list. None of it takes longer than an afternoon, and all of it prevents exposure that no amount of application code can undo.

Turn off debug output and prove it

Set APP_DEBUG=false and APP_ENV=production. A Laravel debug page renders environment variables, database credentials, and a full stack trace to whoever triggered the error. Debug mode left on has been part of real remote code execution chains in this ecosystem, including CVE-2021-3129 in the Ignition error page. Treat it as a hard gate, not a preference.

php artisan about --only=environment

That command prints the active environment, the debug flag, and whether configuration is cached. Run it on the server. The only environment that matters is the one serving traffic.

Cache configuration and stop reading env at runtime

After php artisan config:cache, every env() call outside a config file returns null. Teams usually discover this when a webhook signature check starts failing silently. Read values through config() in application code, and keep env() inside config/*.php where it belongs.

Ship only what production needs

  • Install with composer install --no-dev --optimize-autoloader so dev tooling never reaches the server.

  • Keep .env outside the web root and readable only by the deploy user.

  • Point the document root at public/, never at the project root.

  • Force HTTPS and set SESSION_SECURE_COOKIE=true so cookies never travel in the clear.

  • Configure TrustProxies if you sit behind a load balancer. Otherwise Laravel sees the balancer's IP and your rate limiting protects nothing.

Add the security headers Laravel does not ship

public function handle(Request $request, Closure $next): Response
{
 $response = $next($request);

 $response->headers->set('X-Content-Type-Options', 'nosniff');
 $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
 $response->headers->set('Content-Security-Policy', "default-src 'self'");

 return $response;
}

Register that middleware globally in bootstrap/app.php. Start the Content Security Policy in report only mode, watch the violations for a week, then enforce. A policy that blocks your own assets gets switched off by the first developer it inconveniences.

Authorization and authentication in a secure Laravel app

Validation confirms that data looks right. Authorization confirms that this user may touch this row. A secure Laravel app enforces both on every write, and most insecure ones enforce only the first.

A team lead at a mid-size B2B SaaS shipped an invoice export endpoint on a Friday. The FormRequest was thorough, the route sat behind auth, and the query used Eloquent throughout. Two weeks later a trial customer changed the invoice ID in the URL and pulled up another company's totals. Nothing was wrong in the way a linter understands wrong. The controller simply never asked whether the authenticated user's team owned that invoice. The repair took one afternoon, a Policy, a scoped route binding, and a failing test written first.

Use Policies on every write path

use Illuminate\Support\Facades\Gate;

public function update(UpdateInvoiceRequest $request, Invoice $invoice): RedirectResponse
{
 Gate::authorize('update', $invoice);

 $invoice->update($request->validated());

 return back()->with('status', 'Invoice updated.');
}

One detail catches teams moving up from older versions. Laravel 11 and 12 dropped the AuthorizesRequests trait from the generated base controller, so $this->authorize() fails unless you add the trait yourself. Gate::authorize() works anywhere and reads the same. The Laravel authorization documentation covers Policy discovery and Gate responses in full.

Scope route model bindings to the parent

Route::get('/teams/{team}/invoices/{invoice}', [InvoiceController::class, 'show'])
 ->scopeBindings();

Scoped bindings resolve the invoice through the team relationship, so a mismatched pair returns a 404 before your controller runs. For multi-tenant SaaS applications, pair this with a global scope on the tenant column so one forgotten where clause cannot leak rows across accounts.

Keep mass assignment strict

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
 Model::preventLazyLoading(! app()->isProduction());
 Model::preventSilentlyDiscardingAttributes(! app()->isProduction());
}

Never put is_admin, team_id, or role in $fillable and then hand $request->all() to create(). Pass $request->validated() instead, so only the fields your FormRequest declared can reach the model. If you scaffold models often, this php artisan make:model cheatsheet lists the flags that generate a Policy and FormRequests alongside the model.

Harden sessions and API tokens

  • Throttle sign in attempts. Laravel's starter kits rate limit by email and IP. Hand rolled login controllers need the throttle middleware or a direct call to the RateLimiter facade.

  • Rotate the session. Call regenerate() after a successful login and invalidate() on logout, which closes session fixation.

  • Set cookie flags on purpose. In config/session.php keep http_only true, set secure true in production, and choose same_site deliberately. Use lax unless a cross-site flow truly needs none.

  • Give tokens abilities and an expiry. Sanctum tokens live until revoked. Pass an abilities array, set expiration in config/sanctum.php, and check abilities inside the route rather than trusting that a valid token means full access.

  • Expire reset and verification links. temporarySignedRoute() stops a link from working weeks after it was mailed.

Add one more habit. When a user changes a password, end the other sessions with Auth::logoutOtherDevices() and the auth.session middleware enabled. If somebody already had a session, the password change should close it.

Stop injection, XSS, and unsafe uploads at the edge

Input handling is where the framework helps most and where teams override it most often. Both of the patterns below show up in code review every week.

Keep raw SQL parameterized

// Unsafe, user input is interpolated into the statement
$users = DB::select("select * from users where email = '{$request->email}'");

// Safe, the value is sent as a bound parameter
$users = DB::select('select * from users where email =?', [$request->email]);

The same rule applies to whereRaw(), havingRaw(), and orderByRaw(), each of which accepts a bindings array as its second argument. Column names cannot be bound, so when a sort column arrives from the query string, match it against an allow list before it touches the query.

Validate uploads by content, not by name

  • Validate with rules such as required|file|mimes:pdf,png|max:2048, which inspect the real MIME type rather than the extension a browser claimed.

  • Store uploads on a private disk and stream them through a controller that runs a Policy check, instead of dropping them into public/.

  • Let Laravel name the file with store(). getClientOriginalName() is attacker supplied, and path traversal begins there.

The Laravel validation documentation lists the full rule set, including the fluent File rules for dimensions and size limits on images.

The Laravel security checklist to run before every deploy

Print this one. The point of a Laravel security checklist is that it survives the sprint where everybody is tired and the launch date moved.

  1. APP_DEBUG=false and APP_ENV=production confirmed on the server, not in your local file.

  2. Configuration, routes, and views cached, with no env() calls left in application code.

  3. Dependencies installed with --no-dev, and composer audit green in CI.

  4. Every write route covered by a Policy or Gate, verified by a test that asserts a 403.

  5. Route model bindings scoped, and tenant scopes applied on shared tables.

  6. Session cookies secure and HTTP only, HTTPS forced, proxies trusted correctly.

  7. Login, password reset, and public API routes behind the throttle middleware.

  8. Uploads validated by MIME type, stored privately, and served through an authorized route.

  9. Security headers present on the deployed response, checked in a browser rather than assumed.

  10. APP_KEY set, backed up, and different in every environment.

composer audit
php artisan about
php artisan test

Composer's audit command compares installed versions against the published advisory database and exits non zero on a match, which makes it a clean blocking gate. Run it on every build instead of on a calendar reminder.

it('stops a user from updating another team invoice', function () {
 $invoice = Invoice::factory()->create();

 $this->actingAs(User::factory()->create())
 ->put(route('invoices.update', $invoice), ['total' => 100])
 ->assertForbidden();
});

Authorization tests are the only part of this list that keeps working while you sleep. A Policy you added in March sits one careless refactor away from being bypassed in June, and the test is what notices.

How to apply Laravel security best practices to AI generated code

Most Laravel teams now merge some amount of AI written code, and the failure pattern is consistent. Generated controllers validate well, use Eloquent correctly, and forget authorization. A model has no way to know that invoices belong to teams unless something in the pipeline tells it.

An agency inherited a client project where half the admin module had been generated in a hurry. The FormRequests were clean, the migrations were sensible, and not one of the eleven controllers called a Policy. The reviewer found it by counting authorization calls against write actions, which took about ten minutes and prevented a disclosure.

Three checks catch most of it.

  • Count write actions against Policy calls. Every store, update, and destroy should have one.

  • Search for $request->all(). Replace each instance with $request->validated().

  • Read every raw query helper and every {!!!!}. Both are legitimate occasionally and worth a second look every time.

Tooling narrows the gap. Laravel-native intelligence exists for exactly this reason, producing production-ready Laravel apps where Policies, FormRequests, and Pest tests arrive as part of the output rather than as a follow up ticket.

If you are assembling a review workflow around generated code, this comparison of AI code review tools for PHP developers is a practical starting point. You can also describe a feature, read every file that comes back, and judge the authorization layer yourself before it reaches a branch. Get started free.

Security is a habit, not a sprint

Laravel handles the categories that used to sink PHP applications. Parameter binding, output escaping, CSRF tokens, and password hashing come with the framework. What remains is work only your team can do. Decide who owns each record, write the Policy, scope the binding, and prove it with a test that fails the moment the Policy disappears.

Everything else on this list is maintenance. Keep debug output off, cache configuration, install without dev dependencies, audit packages on every build, and force HTTPS end to end. None of it is hard. All of it gets skipped when a deadline moves.

Pick three items and fix them this week. Run php artisan about against production, search the codebase for $request->all(), and add one authorization test. Then put the same pass on the calendar for next quarter. Laravel security best practices only pay off when they are repeatable, and a short list you actually run beats a long one you admire.