Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizMost books put code style at the end, as an afterthought. We put it here, in Chapter 3, because code style is not a finishing touch - it is a foundation. If your team cannot agree on how code looks, every pull request becomes a formatting debate instead of a logic review.
Taylor Otwell settled this for the Laravel community. Laravel follows PSR-12 with a few opinionated additions, and the official tool for enforcing it is Laravel Pint.
Laravel Pint
Laravel Pint is a zero-configuration code style fixer built on top of PHP-CS-Fixer. It ships with every new Laravel application. Out of the box, Pint uses the laravel preset, which is Taylor's preferred style. You do not need a configuration file. You do not need to debate tabs versus spaces. You run Pint, and your code looks like Laravel code.
To fix the style of every PHP file in your project, run Pint with no arguments:
./vendor/bin/pint
If you want to see what Pint would change without actually modifying any files, use the --test flag. This is useful in CI pipelines where you want to fail a build on style violations rather than auto-fix them:
./vendor/bin/pint --test
You can also target specific files or directories instead of scanning the entire project:
./vendor/bin/pint app/Models
./vendor/bin/pint app/Http/Controllers/UserController.php
If you need to customize rules, create a pint.json file in your project root:
{
"preset": "laravel",
"rules": {
"concat_space": {
"spacing": "one"
},
"ordered_imports": {
"sort_algorithm": "length"
}
}
}
The best practice is to run Pint automatically. Add it to your CI pipeline, your pre-commit hooks, or your editor's save action. Code style should never be a manual task.
Type Hints and Return Types
Type hints and return types serve as documentation, enable static analysis, and catch bugs before they reach production. Use them everywhere:
// Good: fully typed
public function findActiveUsers(int $limit = 10): Collection
{
return User::where('active', true)
->limit($limit)
->get();
}
public function calculateDiscount(Order $order, float $percentage): Money
{
return $order->total->multiply($percentage / 100);
}
public function markAsPaid(Invoice $invoice): void
{
$invoice->update(['paid_at' => now()]);
}
// Bad: no types — what does this accept? What does it return?
public function findActiveUsers($limit = 10)
{
return User::where('active', true)->limit($limit)->get();
}
Use nullable types when a value might not exist:
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
Use union types when a method can return different types:
public function resolve(string $key): string|int|null
{
return config($key);
}
The same applies to class properties. Use typed properties instead of relying on docblocks or hoping the right value is assigned. The readonly keyword is especially useful for injected dependencies — it prevents reassignment after construction:
// Good: typed properties
class OrderService
{
public function __construct(
private readonly OrderRepository $repository,
private readonly int $maxRetries = 3,
) {}
}
// Bad: untyped — anything could end up in these
class OrderService
{
private $repository;
private $maxRetries;
}
Typed properties make your code predictable. If someone tries to assign a string to $maxRetries, PHP catches it immediately instead of letting it silently break something downstream.
DocBlocks
A docblock is a special comment that starts with /** and sits above a class, method, or property. Tools like PHPDoc and IDEs read these comments to understand your code — what a method accepts, what it returns, and what it does. Before PHP had type hints, docblocks were the only way to document types.
Now that PHP has typed properties, return types, and parameter types, most of your code is already self-documenting. That means most docblocks are just noise — they repeat what the code already says:
// Bad: the docblock adds nothing
/**
* Get the user's full name.
*
* @return string
*/
public function fullName(): string
{
return "{$this->first_name} {$this->last_name}";
}
// Good: the method signature says it all
public function fullName(): string
{
return "{$this->first_name} {$this->last_name}";
}
Only add a docblock when it provides information that the type system cannot express. The most common case is describing the shape of arrays and collections:
/**
* @param array<string, mixed> $attributes
* @return Collection<int, User>
*/
public function findMatching(array $attributes): Collection
{
return User::where($attributes)->get();
}
Here the docblock is useful — array and Collection tell you nothing about what is inside them. array<string, mixed> and Collection<int, User> do.
Another good use is documenting exceptions or non-obvious side effects:
/**
* @throws PaymentFailedException
*/
public function charge(Order $order): PaymentIntent
{
return $this->gateway->charge($order->total);
}
The rule is simple: if the docblock repeats the signature, delete it. If it adds information the signature cannot express, keep it.
Static Analysis
Static analysis means checking your code for bugs without running it. Instead of waiting for something to break in production, a static analysis tool reads your files, follows how data moves through your methods, and tells you about problems before they happen. Think of it as a spell-checker for your logic.
Type hints are good - they tell PHP what types to expect. Static analysis takes that further. PHPStan reads your entire codebase and catches bugs that tests might miss - calling a method that does not exist, passing the wrong type to a function, or using a variable that might be null. Larastan is a PHPStan extension that understands Laravel's magic - Eloquent models, facades, collections, and more.
Unlike Pint, Laravel does not ship with PHPStan or any configuration for it. You need to install it yourself:
composer require --dev larastan/larastan
Then create a phpstan.neon.dist configuration file in your project root:
includes:
- vendor/larastan/larastan/extension.neon
parameters:
paths:
- app/
level: 6
checkMissingIterableValueType: false
You will notice PHPStan uses two possible config filenames: phpstan.neon.dist and phpstan.neon. The .dist file is the one you commit to your repository — it holds the shared configuration that everyone on your team uses. The phpstan.neon file (without .dist) is for local overrides. If PHPStan finds both, it uses phpstan.neon and ignores the .dist file. This way, a developer can temporarily lower the level or ignore a path on their machine without changing the committed config. Add phpstan.neon to your .gitignore so local overrides stay local.
PHPStan has levels from 0 (loose) to 9 (strictest). Start at level 5 or 6 and work your way up:
| Level | What It Checks |
|---|---|
| 0 | Basic checks: unknown classes, functions, methods |
| 1 | Possibly undefined variables |
| 2 | Unknown methods on mixed types |
| 3 | Return types |
| 4 | Dead code, unreachable statements |
| 5 | Argument types passed to methods |
| 6 | Missing type hints on properties |
| 7 | Union types handled correctly |
| 8 | Nullable types handled correctly |
| 9 | Mixed type is never used |
Run the analysis:
./vendor/bin/phpstan analyse
Larastan understands Laravel-specific patterns that would confuse plain PHPStan:
// Larastan knows User::where() returns Builder<User>
// Larastan knows $user->posts returns HasMany<Post>
// Larastan knows config('app.name') returns string|null
// Larastan knows Route::get() accepts a closure or controller array
A word of caution: Static analysis can be annoying if it is not set up well. If you crank the level too high on a codebase that is not ready, you will get hundreds of errors and your team will just start ignoring them. If you are adding PHPStan to an existing project, start at a low level, fix what you can, and use a baseline to track the rest. Do not add it to your CI/CD pipeline until your codebase passes cleanly - a pipeline that always fails is worse than no pipeline at all. It is also worth spending some time reading the PHPStan documentation to understand how rules and levels work before you roll it out.
Strict Types Declaration
By default, PHP silently coerces types. If a function expects an int and you pass the string "3", PHP quietly converts it and moves on. This can mask real bugs:
function calculateTotal(int $quantity, float $price): float
{
return $quantity * $price;
}
// PHP silently converts "3" to 3 — no error, no warning
calculateTotal("3", 19.99); // Returns 59.97
Adding declare(strict_types=1) at the top of a file changes this behavior. PHP will no longer coerce types for you — if the types do not match, it throws a TypeError immediately:
<?php
declare(strict_types=1);
function calculateTotal(int $quantity, float $price): float
{
return $quantity * $price;
}
// TypeError: Argument #1 ($quantity) must be of type int, string given
calculateTotal("3", 19.99);
Laravel itself does not use declare(strict_types=1) — the framework is designed to be flexible with type coercion. However, in your own application code, enabling strict types is worth considering. It forces you and your team to be explicit about types, which pairs well with PHPStan to catch problems early.
Whether you adopt strict types is a team decision. If you do, be consistent — apply it across all your application files rather than mixing strict and non-strict files, which can lead to confusing behavior at the boundaries.
A word of caution: Adding
strict_typesto an existing codebase - especially a large one - can break code that has been working fine for years. Every place where a string quietly became an integer will now throw an error. If you are starting a new project, strict types are easy to adopt from day one. If you are working on an existing codebase, introduce them gradually — file by file, with tests covering each change — rather than turning it on everywhere at once.
Automated Refactoring with Rector
Pint fixes how your code looks. PHPStan tells you what is wrong. Rector goes a step further - it rewrites your code for you. Rector reads your PHP files, applies a set of rules, and changes the code automatically. It can rename deprecated method calls, add type declarations, replace old patterns with modern ones, and even upgrade your code from one Laravel version to the next.
Install Rector with the Laravel extension:
composer require --dev driftingly/rector-laravel
Then create a rector.php configuration file in your project root. The simplest setup uses Rector's LaravelSetProvider, which automatically detects your Laravel version from composer.json and applies the matching rules:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use RectorLaravel\Set\LaravelSetProvider;
return RectorConfig::configure()
->withSetProviders(LaravelSetProvider::class)
->withComposerBased(laravel: true);
Run it in dry-run mode first to preview what would change:
./vendor/bin/rector --dry-run
When you are happy with the proposed changes, run it without the flag to apply them:
./vendor/bin/rector
Targeted Rule Sets
Instead of applying everything at once, you can pick specific rule sets for the improvements you care about. For example, the LARAVEL_IF_HELPERS set replaces verbose conditional patterns with Laravel's concise helpers:
// Before: manual if + abort
if (! $user->isAdmin()) {
abort(403);
}
// After: Rector rewrites it to
abort_if(! $user->isAdmin(), 403);
The LARAVEL_TYPE_DECLARATIONS set adds return types and parameter types to your code based on how methods are actually used — a great way to prepare your codebase for higher PHPStan levels.
To use specific sets, reference them in your configuration:
use RectorLaravel\Set\LaravelSetList;
return RectorConfig::configure()
->withSets([
LaravelSetList::LARAVEL_IF_HELPERS,
LaravelSetList::LARAVEL_TYPE_DECLARATIONS,
]);
If you are upgrading between Laravel versions, you can target a specific version level. For example, to apply all rules up to Laravel 12:
use RectorLaravel\Set\LaravelLevelSetList;
return RectorConfig::configure()
->withSets([
LaravelLevelSetList::UP_TO_LARAVEL_120,
]);
Rector is powerful, but it is also opinionated — always review its changes before committing. Run your tests after every Rector pass. Treat it as a very fast junior developer: it does the tedious work, but you still review the pull request.
Laravel Shift
If you want to take automated upgrades even further, Laravel Shift is a paid service built specifically for upgrading Laravel applications between major versions. While Rector applies individual code transformations, Shift handles the full upgrade - configuration changes, dependency updates, namespace adjustments, removed features, and everything documented in Laravel's upgrade guide.
The workflow is simple: you sign in with your GitHub, Bitbucket, or GitLab account, point Shift at your repository, and it opens a pull request with atomic commits and detailed comments explaining every change it made. You review the PR, run your tests, and merge.
Shift supports upgrades all the way from Laravel 4.2 to the latest release. Individual shifts cost between $9 and $39, or you can subscribe to a Shifty Plan starting at $99 per year for unlimited runs and automatic PRs whenever Laravel tags a new release.
Shift works best on projects that follow Laravel conventions closely. If your codebase is heavily customized or has drifted far from the standard Laravel structure, you may need to fix some things by hand after the automated run. That said, even in those cases, Shift handles most of the repetitive work and lets you focus on the parts that actually need your attention.
Laravel Coding Style
Here is a summary of the key style rules that Taylor follows in the Laravel framework source code:
Braces and spacing:
// Opening brace on the same line for classes and methods
class UserController extends Controller
{
public function index(): View
{
// Code here
}
}
// Single blank line between methods
public function index(): View
{
return view('users.index');
}
public function show(User $user): View
{
return view('users.show', compact('user'));
}
Early returns to reduce nesting:
// Good: early return
public function update(UpdateUserRequest $request, User $user): RedirectResponse
{
if ($user->isLocked()) {
return back()->with('error', 'Account is locked.');
}
$user->update($request->validated());
return redirect()->route('users.show', $user);
}
// Bad: deeply nested
public function update(UpdateUserRequest $request, User $user): RedirectResponse
{
if (! $user->isLocked()) {
$user->update($request->validated());
return redirect()->route('users.show', $user);
} else {
return back()->with('error', 'Account is locked.');
}
}
Trailing commas in multi-line arrays and arguments:
// Good: trailing comma makes diffs cleaner
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
Use ! instead of not and prefer blank()/filled() helpers:
// Laravel style
if (! $user->isActive()) { }
if (blank($value)) { }
if (filled($value)) { }
// Not Laravel style
if ($user->isActive() === false) { }
if (empty($value) || $value === '') { }
Prefer string interpolation over concatenation:
// Good: easy to read
$greeting = "Hello, {$user->name}. You have {$count} notifications.";
// Bad: hard to follow with dots everywhere
$greeting = 'Hello, ' . $user->name . '. You have ' . $count . ' notifications.';
Use curly braces {$var} inside double-quoted strings. For simple variables you can skip the braces, but using them consistently makes the code easier to scan — especially when accessing properties or array keys.
Ternary operators - keep them short or do not use them:
A ternary is fine for simple assignments:
$status = $user->isAdmin() ? 'admin' : 'member';
When it gets longer, break it into multiple lines:
$label = $order->isPaid()
? 'Completed'
: 'Pending payment';
If you find yourself nesting ternaries, stop and use an if statement instead. Nested ternaries are hard to read and easy to get wrong:
// Bad: nested ternary — what does this even return?
$role = $user->isAdmin() ? 'admin' : ($user->isEditor() ? 'editor' : 'viewer');
// Good: just use if statements
if ($user->isAdmin()) {
$role = 'admin';
} elseif ($user->isEditor()) {
$role = 'editor';
} else {
$role = 'viewer';
}
For simple null checks, use the null coalescing operator instead of a ternary:
// Good: null coalescing
$name = $user->nickname ?? $user->name;
// Unnecessary: ternary for null check
$name = $user->nickname !== null ? $user->nickname : $user->name;
Use array syntax for validation rules:
When writing validation rules, use arrays instead of pipe-delimited strings. Arrays are easier to read, easier to diff in pull requests, and let you mix string rules with Rule objects without awkward syntax:
// Good: array syntax
$request->validate([
'email' => ['required', 'email', 'unique:users'],
'name' => ['required', 'string', 'max:255'],
'role' => ['required', Rule::in(['admin', 'editor', 'viewer'])],
]);
// Bad: pipe syntax — harder to read and cannot mix with Rule objects
$request->validate([
'email' => 'required|email|unique:users',
'name' => 'required|string|max:255',
]);
CI/CD Pipeline
Running these tools locally is great, but the real power comes when you enforce them automatically on every push and pull request. GitHub Actions makes this straightforward.
Before setting up CI, consider adding Composer scripts so your team can run the same checks locally with a single command:
{
"scripts": {
"lint": "./vendor/bin/pint --test",
"fix": "./vendor/bin/pint",
"analyse": "./vendor/bin/phpstan analyse",
"refactor": "./vendor/bin/rector --dry-run",
"check": [
"@lint",
"@analyse",
"@refactor"
]
}
}
Now composer check runs Pint, PHPStan, and Rector in one go — the same checks your CI pipeline will enforce.
Automated Code Style Fixing with Pint
This workflow runs Pint on every push and pull request that touches PHP files, and automatically commits the fixes back to the branch:
name: Fix PHP code style issues
on:
push:
branches: [main]
paths:
- '**.php'
pull_request:
branches: [main]
paths:
- '**.php'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
php-code-styling:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
- name: Fix PHP code style issues
uses: aglipanci/laravel-pint-action@2.6
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: Fix styling
Let's break down how this works:
paths: ['**.php']- The workflow only triggers when PHP files change. There is no reason to run a PHP style fixer when you edit a README or a JavaScript file.concurrency- If you push twice in quick succession, the first run is cancelled. This saves runner minutes and prevents two runs from trying to commit style fixes at the same time.permissions: contents: write- The workflow needs write access to push the auto-fix commit back to the branch.ref: ${{ github.head_ref }}- On pull requests, this checks out the actual feature branch (not the merge commit), so the style fix commit lands on the correct branch.aglipanci/laravel-pint-action@2.6- A community action that sets up PHP and runs Pint. It handles the PHP setup so you don't have to.stefanzweifel/git-auto-commit-action@v7- After Pint runs, this action checks if any files were modified. If Pint changed something, it commits and pushes the fixes automatically. If nothing changed, it does nothing.
The result: developers never need to argue about code style in reviews. Push your code, and CI formats it for you.
Static Analysis with PHPStan
This workflow runs PHPStan to catch bugs and type errors:
name: PHPStan
on:
push:
branches: [main]
paths:
- '**.php'
- 'phpstan.neon.dist'
- 'phpstan-baseline.neon'
- '.github/workflows/phpstan.yml'
pull_request:
branches: [main]
paths:
- '**.php'
- 'phpstan.neon.dist'
- 'phpstan-baseline.neon'
- '.github/workflows/phpstan.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
phpstan:
name: PHPStan
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install composer dependencies
uses: ramsey/composer-install@v3
- name: Run PHPStan
run: ./vendor/bin/phpstan --error-format=github
Here is what each piece does:
paths- Notice this workflow triggers on more than just PHP files. Changes tophpstan.neon.dist(the configuration),phpstan-baseline.neon(the baseline of ignored errors), and the workflow file itself all trigger a re-run. If you tighten your PHPStan rules, you want to know immediately if the codebase still passes.shivammathur/setup-php@v2- Sets up the exact PHP version your project uses. Thecoverage: noneoption skips installing Xdebug, which speeds up the job since PHPStan does not need code coverage.ramsey/composer-install@v3- Installs your Composer dependencies with built-in caching. On subsequent runs, it restores thevendor/directory from cache, making the install step near-instant.--error-format=github- This is the key flag. Instead of printing errors to the console, PHPStan outputs them in GitHub's annotation format. This means errors appear as inline annotations directly on the pull request diff, right next to the line that caused the problem.
Unlike the Pint workflow, PHPStan does not auto-fix anything - it fails the build. This is intentional. A type mismatch might mean the code is wrong, or it might mean the type hint is wrong. Only the developer can decide which one to fix.
Automated Refactoring with Rector
This workflow runs Rector in dry-run mode. It checks whether there are any refactoring rules that have not been applied yet. If there are, the build fails. The idea is that developers should run Rector locally, review what it changed, and commit on purpose — not have CI silently rewrite their code:
name: Rector
on:
push:
branches: [main]
paths:
- '**.php'
- 'rector.php'
- '.github/workflows/rector.yml'
pull_request:
branches: [main]
paths:
- '**.php'
- 'rector.php'
- '.github/workflows/rector.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rector:
name: Rector
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install composer dependencies
uses: ramsey/composer-install@v3
- name: Run Rector
run: ./vendor/bin/rector --dry-run
Here is what each piece does:
paths— Like the PHPStan workflow, this triggers on PHP files, the Rector configuration (rector.php), and the workflow file itself. Changing a rule set should immediately verify the codebase still passes.--dry-run— Rector checks the code and reports what it would change, but does not modify any files. If there are pending changes, the build fails.
The workflow uses the same shivammathur/setup-php and ramsey/composer-install actions as the PHPStan workflow, keeping the setup consistent across all your CI jobs.
Why Separate Workflows?
You might wonder why we don't combine Pint, PHPStan, and Rector into a single workflow. There are a few reasons:
- Different behaviors — Pint auto-fixes and commits. PHPStan and Rector fail the build. Mixing these in one workflow creates confusing logic where part of the job succeeds and part fails.
- Independent triggers — PHPStan triggers on
phpstan.neon.distchanges, Rector triggers onrector.phpchanges, and Pint only needs PHP files. Keeping them separate means each workflow has clean, focused trigger rules. - Clear feedback — When a workflow fails, you immediately know what failed: style, analysis, or refactoring. A single combined workflow forces you to dig through logs to figure out which tool broke.
All three workflows use timeout-minutes: 5 as a safety net. If something hangs, the job stops after five minutes instead of burning through your Actions quota.
With these tools in place, every chapter that follows will produce code that is not only well-structured but also clean, checked, and ready to ship. This is the foundation that makes everything else possible.
Summary
- Code style is a foundation, not a finishing touch. Settle formatting debates once with Laravel Pint, then automate it so nobody thinks about it again.
- Use type hints everywhere. Parameters, return types, and typed properties serve as documentation, enable static analysis, and catch bugs before they reach production.
- Only write docblocks that add information. If a docblock repeats what the type signature already says, delete it. Keep docblocks for array shapes, collection types, and thrown exceptions.
- Static analysis catches bugs without running code. PHPStan with Larastan understands Laravel's magic and finds problems your tests might miss. Start at a low level and work your way up.
- Strict types are a team decision. Laravel does not use them, but enabling
declare(strict_types=1)in your own code forces explicit types. Add them gradually to existing projects. - Automate refactoring with Rector. It rewrites your code to use modern PHP and Laravel patterns. Use Laravel Shift for full version upgrades.
- Prefer string interpolation over concatenation, array syntax over pipe-delimited validation rules, and simple ternaries over nested ones.
- Keep ternaries short. If you are nesting them, use
ifstatements instead. Use the null coalescing operator (??) for null checks. - Enforce everything in CI. Separate workflows for Pint, PHPStan, and Rector give clear feedback, independent triggers, and clean separation of concerns.
- The code you do not write is the code that never has bugs. Let tools handle style, types, and refactoring so you can focus on logic.
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz