Best Practices
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. It collects, in one place, the practices that the rest of this section shows in detail. Each item is a rule of one or two sentences with a link to the page that explains it and shows it running. The books consulted for this section (listed on the section landing page) contain much of this advice in older forms; where it has aged, the item says what replaced it, and What’s Changed and Upgrading has the full table. The two examples below were run: the plain PHP one with the PHP 8.3 command-line interpreter, and the Laravel one as a controller, an action and two Pest tests added to the Bookshelf application used across this section.
Plain PHP
These apply to any PHP code, and they are what a framework does for you, so knowing them explains what Laravel is protecting against.
-
Escape on output, for the context. Store input as it was typed and escape it where it is printed:
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')in HTML text and attributes,json_encode()for JavaScript,rawurlencode()in URLs. "Sanitising" input on the way in damages data and still misses contexts. See Forms, Validation and Output Escaping. -
Validate on the server, with an allow-list. Check type, length and format with
filter_var()or explicit code, and reject what does not match. Browser validation is a convenience for the user, not a control. See Forms, Validation and Output Escaping. -
Use prepared statements for every value in SQL. With PDO, pass values as parameters and keep
PDO::ERRMODE_EXCEPTION(the default since PHP 8.0). Identifiers such as column names for sorting cannot be bound, so they come from an allow-list. See Databases with PDO. -
Redirect after a successful POST so that reloading the page does not submit the form again. See Forms, Validation and Output Escaping.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // the default since PHP 8.0
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL)');
$title = $_POST['title'] ?? '<script>alert(1)</script> & Co'; // untrusted input, stored as typed
$insert = $pdo->prepare('INSERT INTO books (title) VALUES (:title)'); // data never becomes SQL
$insert->execute(['title' => $title]);
$e = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
foreach ($pdo->query('SELECT id, title FROM books') as $row) {
echo '<li><a href="/books/', (int) $row['id'], '">', $e($row['title']), "</a></li>\n"; // escaped on output
}
The row was stored exactly as typed and printed as
<li><a href="/books/1"><script>alert(1)</script> & Co</a></li>.
-
Harden sessions. Set
session.use_strict_mode=1,session.cookie_httponly=1,session.cookie_secure=1andsession.cookie_samesite=Lax, callsession_regenerate_id(true)at login, and destroy the session and its cookie at logout. See Sessions and Cookies. -
Hash passwords with
password_hash()and check them withpassword_verify(), rehashing withpassword_needs_rehash()when the cost changes. Never usemd5(),sha1()or a home-made salt. See Authentication Without a Framework. -
Protect state-changing requests against CSRF with a per-session token,
SameSitecookies and, in current browsers, theSec-Fetch-Siteheader. See Web Security Fundamentals. -
Treat uploads as hostile. Check
$_FILES[...]['error'], detect the type from the content withfinfo, not from the client’s name or MIME type, store the file outside the document root under a generated name, and re-encode images. See File Uploads and Images. -
Keep secrets out of the code and the document root. Database passwords and API keys come from environment variables or a secrets manager, and only
public/is served. See Web Security Fundamentals. -
Show errors to developers, not to visitors.
display_errors=Offandlog_errors=Onin production, with a handler that logs the exception and returns a plain 500 page. See Error Handling and Logging. -
Set timeouts on outgoing HTTP calls and never fetch a URL taken from user input without an allow-list, which is how server-side request forgery happens. See HTTP Clients, APIs and Email.
-
Use Composer, PSR-4 autoloading and a supported PHP version rather than
requirechains and copied libraries. See Templating, Routing and Middleware by Hand and Composer and Packages.
Laravel: structure
-
Keep controllers thin. A controller method reads the request, calls one thing that does the work and returns a response. Validation goes to a Form Request, authorization to a policy, and the work to an action or service class that a command, a job or a test can also call. See Controllers.
-
Validate with Form Requests for anything beyond a couple of fields, and use
validated()orsafe()rather thanall(). Laravel 13’s#[FailOnUnknownFields]rejects keys that have no rule. See Validation and Form Requests. -
Authorize with policies, attached to the route or controller (
canmiddleware, or Laravel 13’s#[Authorize]attribute) so the check cannot be forgotten in one method. See Authorization: Gates and Policies. -
Inject dependencies through the constructor or the method and let the container build them. Facades are fine in application code. Binding an interface is worth it only where there is more than one implementation or a test needs a fake. See Service Container, Providers and Facades.
-
Follow the default skeleton. Configure middleware, exceptions and routing in
bootstrap/app.php, and letmake:*commands create directories when they are needed. See Directory Structure and Configuration.
The controller below follows the first three rules. The Form Request is the StoreBookRequest from
Validation and Form Requests, and the policy’s create()
method allows librarians:
<?php
// app/Http/Controllers/ShelfBookController.php
namespace App\Http\Controllers;
use App\Actions\AddBook;
use App\Http\Requests\StoreBookRequest;
use App\Models\Book;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth')]
class ShelfBookController extends Controller
{
#[Authorize('create', Book::class)] // BookPolicy::create()
public function store(StoreBookRequest $request, AddBook $addBook): RedirectResponse
{
$book = $addBook->handle($request->safe()->only(['author_id', 'title', 'isbn', 'published_year']));
return to_route('books.show', $book)->with('status', 'Book added');
}
}
Source: Laravel — Controller Middleware
<?php
// app/Actions/AddBook.php
namespace App\Actions;
use App\Jobs\SyncBookWithCatalogue;
use App\Models\Book;
use Illuminate\Support\Facades\DB;
class AddBook
{
public function handle(array $data): Book
{
return DB::transaction(function () use ($data) {
$book = Book::create($data);
SyncBookWithCatalogue::dispatch($book)->afterCommit(); // slow work: queued, and only if the insert commits
return $book;
});
}
}
Two tests covered it. A librarian’s request redirected to the new book and pushed one SyncBookWithCatalogue job. A
member got 403 before validation ran, because the #[Authorize] middleware runs before the Form Request is resolved.
A librarian with an empty form got 422 errors for title, isbn and author_id. In the failing cases no book was
stored and no job was pushed.
Laravel: data
-
Eager-load what a page uses. Use
with()for relations shown in a list andwithCount()for counts, and check the query count in development. See Query Performance and N+1 and Eloquent Relationships. -
Turn on strict mode outside production.
Model::shouldBeStrict(! app()->isProduction())in a service provider makes lazy loading, silently discarded attributes and missing attributes throw instead of passing unnoticed. See Eloquent Models. -
Declare what may be mass-assigned with
#[Fillable](or$fillable) and never pass$request->all()tocreate()orupdate(). See Eloquent Models. -
Cast columns with the
casts()method, including enums, dates and encrypted values, so the model returns PHP types. See Eloquent Casts, Accessors and Serialization. -
Change the schema only through migrations, keep a working
down()while it is still useful, and never edit a migration that has already run in production. Add indexes for the columns that are filtered and sorted on. See Migrations, Seeders and Factories. -
Wrap multi-step writes in
DB::transaction(), and remember that query-builder updates and deletes skip model events. See Database and Query Builder. -
Paginate lists and process large tables in chunks with
chunkById()orlazyById(), neverall(). See Collections and Pagination.
Laravel: background work
-
Queue anything slow or unreliable: mail, calls to other services, image processing and imports. The request returns, and a worker retries a failure. See Queues and Jobs.
-
Dispatch after commit (
afterCommit()orafter_commiton the connection), or a worker can pick up a job for a row that does not exist yet. See Queues and Jobs. -
Make jobs idempotent and bounded. A job can run more than once, so it checks before it acts, and it sets
Tries,BackoffandTimeout. Errors that retrying cannot fix callfail(). See Queues and Jobs. -
Run workers and the scheduler under a process manager, restart workers on every deploy, and use
onOneServer()with more than one scheduler. See Task Scheduling and Deployment. -
Use events for side effects that other parts of the code react to, not to hide the main flow, which an action class shows more clearly. See Events and Listeners.
-
Give every outgoing HTTP call a timeout and a bounded retry. Retries in the HTTP client multiply with the job’s own tries. See HTTP Client.
-
Cache with an expiry and a key that changes with the data, and use atomic locks for work that must not run twice. See Cache and Rate Limiting.
Laravel: configuration and production
-
Call
env()only inconfig/files. Afterconfig:cache,env()elsewhere returnsnull. See Directory Structure and Configuration. -
Run
php artisan optimizeon deploy (configuration, events, routes and views cached), with OPcache enabled and timestamp checks off. See Deployment. -
Set
APP_DEBUG=falseandAPP_ENV=productionin production, and never commit.env. Encrypted environment files (env:encrypt) or the platform’s secret store hold production values. See Security: CSRF, Encryption and Hashing. -
Configure trusted proxies and hosts behind a load balancer, or URLs, HTTPS detection and client IPs are wrong. See Middleware.
-
Log to
stderror a central store with context, and watch production with Pulse, Nightwatch or an error tracker rather than Telescope. See Monitoring and Debugging. -
Build one image for web, worker and scheduler and change only the command. See Docker and Sail.
-
Measure before optimising. Fix queries first. Octane removes the boot cost but brings back state between requests. See Octane and Performance.
Laravel: security
-
Keep the defaults on: request forgery protection (
PreventRequestForgery), escaped Blade output with{{ }}, and hashed passwords. Use{!! !!}only for HTML the application produced itself. See Security: CSRF, Encryption and Hashing and Blade Templates. -
Rate-limit login, registration, password reset and public APIs with named limiters. See Cache and Rate Limiting.
-
Use a starter kit or Fortify for authentication rather than writing login, reset and two-factor flows by hand. See Authentication.
-
Use Sanctum for first-party SPAs and mobile tokens, and Passport only when you need an OAuth 2 server. Give tokens abilities and expiry. See API Authentication: Sanctum and Passport.
-
Use signed URLs for links that act without a login, such as unsubscribe or download links. See Routing.
-
Store uploads on a private disk and serve them through a route that checks permission, or with temporary URLs. See File Storage and Images.
-
Send a Content Security Policy with nonces for Vite and Livewire scripts. See Vite and Frontend Assets.
Laravel: tests
-
Write feature tests through HTTP (
$this->post(...)) for each route’s success, validation and authorization cases, as above. See Testing. -
Reset the database per test with
RefreshDatabaseand build data with factories. See Testing and Migrations, Seeders and Factories. -
Fake the edges:
Queue::fake(),Mail::fake(),Http::fake()withpreventStrayRequests(), andStorage::fake(), so tests neither send nor wait. See Testing. -
Run the suite in CI on every push, in parallel, with a browser test for the flows that depend on JavaScript. See Browser Testing with Dusk.
Laravel: dependencies and upgrades
-
Stay on a supported Laravel and PHP version. A major release comes out every year and gets two years of security fixes, so the next upgrade is never more than a year away. See What’s Changed and Upgrading.
-
Run
composer auditin CI and keepcomposer.lockcommitted. See Security: CSRF, Encryption and Hashing. -
Prefer first-party and well-maintained packages, and check activity and maintainers before adding one. See Package Development and Ecosystem.
-
Format with Pint and analyse with Larastan (PHPStan) so the style and type errors are found by tools, not in review. See Static Analysis and Refactoring.
Where older books and tutorials are outdated
Most of the advice above appears in older books, in older forms. The changes that matter for this checklist are:
-
mysql_*functions and string-built queries were removed in PHP 7.0. PDO or mysqli with prepared statements replace them. -
magic_quotes,addslashes()and "sanitising" input were never sufficient and were removed (magic quotes in PHP 5.4). Escaping on output and binding parameters replace them. -
md5()orsha1()password hashes were replaced bypassword_hash()in PHP 5.5 and byHash::make()in Laravel. -
Validation and authorization written inside controllers, as in Laravel 5 examples, still works, but Form Requests, policies and Laravel 13’s
#[Authorize]attribute keep them out of the controller. -
$fillablearrays and$castsproperties still work. Laravel 13’s#[Fillable]attribute and thecasts()method (Laravel 11) are the current forms. -
VerifyCsrfTokenand the HTTP Kernel were replaced byPreventRequestForgeryandbootstrap/app.php(Laravel 11 and 13). -
Queue workers started with
queue:listenin production reload the framework for every job.queue:workunder Supervisor, or Horizon, is the current practice.