Laravel Cheat Sheet

This Laravel cheat sheet covers twenty-eight sections, from php artisan serve to Eloquent, Blade, Livewire, queues, Sanctum and Pest. Search it, filter it by level, copy any line with one click. Written for Laravel 12 and 13, with a badge on every feature newer than Laravel 10.

28sections
305snippets
L13up to date
0signup needed

Updated September 11, 2026, checked against the Laravel 13 release notes. Print it for a PDF copy.

/
level

0 results

Nothing matches that query. Try a shorter keyword such as route, eloquent or queue job. Several words are combined, so every one of them has to match.

01

Getting Started

install, structure, environment

Create a Project

L11+core

The installer asks about a starter kit, testing framework and database

composer global require laravel/installer
laravel new shop
cd shop
php artisan serve

Without the installer

composer create-project laravel/laravel shop

Local environments

# macOS and Windows, zero config
# https://herd.laravel.com

# Docker based, ships with the project
./vendor/bin/sail up -d
sail artisan migrate

Requirements

Laravel 13   PHP 8.3 to 8.5   released March 2026, the current major
Laravel 12   PHP 8.2 to 8.5   security fixes until February 2027
Laravel 11   PHP 8.2 to 8.4   security fixes until March 2026
Composer 2, Node 18+ for Vite
A database: SQLite is the default, MySQL, MariaDB, PostgreSQL or SQL Server

Directory Structure

L11+core

What lives where in the slim skeleton

PathHolds
app/Http/Controllerscontrollers, one class per resource or action
app/ModelsEloquent models
app/Http/Requestsform request classes for validation
app/Http/Middlewareyour own middleware, created on demand
app/Jobs, app/Events, app/Listeners, app/Mail, app/Notificationscreated by make commands as you need them
app/Providers/AppServiceProvider.phpthe only provider that ships, bindings and boot logic
bootstrap/app.phpmiddleware, exception handling, routing setup
routes/web.php, routes/api.php, routes/console.phpweb routes, API routes, Artisan closures and the scheduler
database/migrations, factories, seedersschema, fake data, seed data
resources/viewsBlade templates
resources/css, resources/jsfront end source compiled by Vite
configone file per subsystem, reads from .env
storagelogs, cache, sessions, uploaded files
publicthe web root, index.php and built assets
tests/Feature, tests/UnitPest or PHPUnit tests

Environment and Config

core

The .env file, never committed

APP_NAME=Shop
APP_ENV=local
APP_KEY=base64:...
APP_DEBUG=true
APP_URL=http://shop.test

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=shop
DB_USERNAME=root
DB_PASSWORD=

QUEUE_CONNECTION=database
CACHE_STORE=redis
SESSION_DRIVER=database

Read config, never env, outside the config folder

// config/services.php
'stripe' => ['key' => env('STRIPE_KEY')],

// anywhere else
$key = config('services.stripe.key');
$name = config('app.name', 'Laravel');   // with a default

Generate the encryption key, required before anything runs

php artisan key:generate

Environment checks in code

if (app()->environment('local')) { }
if (app()->isProduction()) { }
app()->environment(['staging', 'production']);

Starter Kits and Front End

L12+everyday

Laravel 12 starter kits, chosen at install time

React      Inertia + React + TypeScript + shadcn/ui
Vue        Inertia + Vue + TypeScript + shadcn-vue
Livewire   Blade + Livewire + Flux UI
None       plain Blade, bring your own

Vite, the asset pipeline

npm install
npm run dev      # hot reload while developing
npm run build    # minified assets for production

Load the compiled assets in a layout

@vite(['resources/css/app.css', 'resources/js/app.js'])

Add an API or broadcasting after the fact

php artisan install:api          # routes/api.php + Sanctum
php artisan install:broadcasting  # routes/channels.php + Reverb
02

Artisan CLI Commands

generators, maintenance, tinker

Make Commands

core

Generators, and the flags that save a trip back to the terminal

CommandCreatesUseful flags
make:model Productapp/Models/Product.php-m migration, -f factory, -s seeder, -c controller, -r resource controller, -a all of them, --pivot
make:controller ProductControllera controller--resource, --api, --invokable, --model=Product, --requests
make:migration create_products_tablea migration--create=products, --table=products
make:request StoreProductRequesta form request
make:middleware EnsureIsAdminmiddleware
make:job ProcessOrdera queued job--sync
make:event OrderShippedan event
make:listener SendReceipta listener--event=OrderShipped, --queued
make:mail OrderReceipta mailable--markdown=mail.orders.receipt
make:notification InvoicePaida notification--markdown
make:policy ProductPolicya policy--model=Product
make:resource ProductResourcean API resource--collection
make:component Alerta Blade component--view for anonymous, --inline
make:command SendRemindersan Artisan command--command=app:send-reminders
make:test ProductTesta feature test--unit, --pest
make:factory ProductFactorya factory--model=Product
make:seeder ProductSeedera seeder
make:enum OrderStatusa PHP enum--string, --int
make:class, make:interface, make:traitplain PHP files

The one liner that scaffolds a whole resource

php artisan make:model Product --all

Everyday Commands

core

Database

php artisan migrate
php artisan migrate --seed
php artisan migrate:fresh --seed     # drop everything and rebuild
php artisan migrate:rollback --step=1
php artisan migrate:status
php artisan db:seed --class=ProductSeeder
php artisan db:show

Routes, and finding the one you want

php artisan route:list
php artisan route:list --path=api/orders
php artisan route:list --name=orders --except-vendor

Caches, clear them when something looks stale

php artisan optimize:clear     # everything below at once
php artisan config:clear
php artisan route:clear
php artisan view:clear
php artisan cache:clear
php artisan event:clear

Queues and the scheduler

php artisan queue:work --tries=3
php artisan queue:listen        # reloads code on change, dev only
php artisan queue:failed
php artisan queue:retry all
php artisan schedule:run
php artisan schedule:work       # runs the scheduler every minute, dev only

Maintenance mode

php artisan down --secret=bypass-token --retry=60
php artisan up

Tinker

everyday

A REPL with the whole app loaded

php artisan tinker

> User::count()
= 42
> $u = User::first()
> $u->orders()->latest()->first()->total
= 129.5
> dispatch(new ProcessOrder($u->orders->first()))

Run one expression and exit

php artisan tinker --execute="echo User::count();"

Custom Commands

advanced

A command class with arguments and options

class SendReminders extends Command
{
    protected $signature = 'app:send-reminders
                            {days=7 : How many days before}
                            {--dry-run : Print without sending}';

    protected $description = 'Email customers about upcoming renewals';

    public function handle(ReminderService $service): int
    {
        $count = $service->send((int) $this->argument('days'), $this->option('dry-run'));
        $this->info("Sent {$count} reminders.");
        return self::SUCCESS;
    }
}

Closure commands in routes/console.php

Artisan::command('app:cleanup {days=30}', function (int $days) {
    $this->comment("Cleaning records older than {$days} days");
})->purpose('Remove stale records');

Prompts, progress and tables inside a command

use function Laravel\Prompts\{text, confirm, select};

$email = text('Email address', required: true);
$plan  = select('Plan', ['free', 'pro', 'team']);
if (! confirm('Create the account?')) return self::FAILURE;

$this->withProgressBar($users, fn ($u) => $u->notify(new Welcome));
$this->table(['Name', 'Email'], $rows);
03

Service Container & Facades

bindings, providers, what a facade is

The Service Container

everyday

Most classes resolve with no configuration, you bind the ones with a choice

// AppServiceProvider::register
$this->app->bind(PaymentGateway::class, StripeGateway::class);        // new instance each time
$this->app->singleton(RateCalculator::class, fn ($app) => new RateCalculator(config('rates')));
$this->app->scoped(RequestContext::class);                              // one per request, reset for Octane
$this->app->instance(Clock::class, new SystemClock);                    // an object you already have
$this->app->bindIf(Mailer::class, SmtpMailer::class);                   // only if nothing bound yet

Resolving, four ways that do the same thing

$gateway = app(PaymentGateway::class);
$gateway = resolve(PaymentGateway::class);
$gateway = App::make(PaymentGateway::class);
$gateway = app()->makeWith(Report::class, ['year' => 2026]);   // with runtime arguments

// or just ask for it
public function __construct(private PaymentGateway $gateway) {}
public function store(Request $request, PaymentGateway $gateway) {}

Contextual binding, different implementations for different consumers

$this->app->when(PhotoController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('s3'));

$this->app->when(ReportController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('local'));

$this->app->when(Importer::class)->needs('$batchSize')->give(500);

Contextual attributes, the same idea on the parameter

use Illuminate\Container\Attributes\{Config, Storage, Auth, Cache, CurrentUser, Log, Tag};

public function __construct(
    #[Config('services.stripe.key')] private string $key,
    #[Storage('s3')] private Filesystem $files,
    #[Cache('redis')] private Repository $cache,
    #[Log('audit')] private LoggerInterface $log,
    #[Tag('reports')] private iterable $reports,
) {}

public function show(#[CurrentUser] User $user) {}

Tagging, extending, and the container events

$this->app->tag([CpuReport::class, MemoryReport::class], 'reports');
$this->app->tagged('reports');                     // iterable of instances

$this->app->extend(Mailer::class, fn ($mailer, $app) => new LoggingMailer($mailer));

$this->app->resolving(Report::class, fn (Report $r) => $r->setLocale(app()->getLocale()));
$this->app->bound(Mailer::class);   $this->app->has(Mailer::class);

Service Providers

L11+everyday

register binds, boot uses

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(PaymentGateway::class, StripeGateway::class);
    }

    public function boot(): void
    {
        Model::shouldBeStrict(! $this->app->isProduction());
        Gate::define('view-reports', fn (User $u) => $u->is_manager);
        View::composer('layouts.app', NavComposer::class);
        Str::macro('initials', fn (string $s) => ...);
    }
}

Add a provider, registered in bootstrap/providers.php

php artisan make:provider BillingServiceProvider

// bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\BillingServiceProvider::class,
];

Deferred providers load only when something they bind is resolved

class ReportServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function register(): void { $this->app->singleton(ReportBuilder::class); }
    public function provides(): array { return [ReportBuilder::class]; }
}

Facades

everyday

What a facade actually is

Cache::get('key');
// is exactly
app('cache')->get('key');
// is exactly
app(Illuminate\Contracts\Cache\Factory::class)->store()->get('key');

// the same service, three ways
Cache::put('k', 1);   cache(['k' => 1]);   $this->cache->put('k', 1);   // injected Repository

Facades you will use daily, and what they resolve

FacadeUnderlying serviceHelper
Route, URLrouter, url generatorroute(), url()
DB, Schemadatabase manager, schema builder
Auth, Gate, Hash, Cryptauth manager, gate, hasher, encrypterauth(), bcrypt(), encrypt()
Cache, Session, Cookiecache repository, session store, cookie jarcache(), session(), cookie()
Config, App, Logconfig repository, application, loggerconfig(), app(), logger()
View, Bladeview factory, compilerview()
Mail, Notification, Event, Queue, Busmailer, dispatchers, queue managerevent(), dispatch()
Storage, Filefilesystem manager, local filesstorage_path()
Http, Process, ConcurrencyHTTP client, process runner, task runner
Validator, Password, RateLimiter, Schedulevalidation factory, broker, limiter, schedulervalidator()

Real time facades, any class becomes one by prefixing the namespace

use Facades\App\Services\Publisher;

Publisher::publish($post);        // resolves App\Services\Publisher and calls publish

// in a test
Publisher::shouldReceive('publish')->once()->with($post);

Swap or spy on a facade in tests

Cache::shouldReceive('get')->once()->with('key')->andReturn('value');
Cache::spy();   ...;   Cache::shouldHaveReceived('put')->once();
Cache::swap($fakeRepository);
Cache::partialMock();
04

Laravel Routing

web.php, parameters, groups, binding

Basic Routes

core

Closures for tiny endpoints, controllers for everything else

use App\Http\Controllers\ProductController;

Route::get('/', fn () => view('home'));
Route::get('/products', [ProductController::class, 'index']);
Route::post('/products', [ProductController::class, 'store']);
Route::put('/products/{product}', [ProductController::class, 'update']);
Route::delete('/products/{product}', [ProductController::class, 'destroy']);

Every verb, several verbs, any verb

Route::get(...);    Route::post(...);   Route::put(...);
Route::patch(...);  Route::delete(...); Route::options(...);

Route::match(['get', 'post'], '/contact', ContactController::class);
Route::any('/webhook', WebhookController::class);

Shortcuts that need no controller at all

Route::view('/about', 'about', ['team' => 12]);
Route::redirect('/old', '/new');            // 302
Route::permanentRedirect('/old', '/new');   // 301

Parameters

core

Required and optional segments

Route::get('/users/{id}', fn (int $id) => "User {$id}");
Route::get('/posts/{post}/comments/{comment}', ...);
Route::get('/search/{term?}', fn (?string $term = null) => ...);

Constraints, inline or global

Route::get('/users/{id}', ...)->whereNumber('id');
Route::get('/posts/{slug}', ...)->whereAlphaNumeric('slug');
Route::get('/orders/{uuid}', ...)->whereUuid('uuid');
Route::get('/plans/{plan}', ...)->whereIn('plan', ['free', 'pro']);
Route::get('/files/{path}', ...)->where('path', '.*');

// in AppServiceProvider::boot, applies everywhere
Route::pattern('id', '[0-9]+');

Route model binding, the query and the 404 are free

Route::get('/products/{product}', function (Product $product) {
    return $product;               // found by id, 404 if missing
});

Route::get('/products/{product:slug}', ...);   // bind by another column

// scoped: the comment must belong to the post
Route::get('/posts/{post}/comments/{comment}', ...)->scopeBindings();

Include soft deleted rows, or customise the lookup

Route::get('/users/{user}', ...)->withTrashed();

// in the model
public function resolveRouteBinding($value, $field = null)
{
    return $this->where('slug', $value)->firstOrFail();
}

Named Routes and URLs

core

Name a route, then never type its path again

Route::get('/products/{product}', [ProductController::class, 'show'])
    ->name('products.show');

route('products.show', $product);                 // /products/12
route('products.show', ['product' => 12, 'tab' => 'reviews']);   // ?tab=reviews
to_route('products.show', $product);              // redirect response

Inspect the current route

request()->routeIs('products.*');
Route::currentRouteName();
url()->current();
url()->previous();

Signed URLs expire and cannot be tampered with

URL::temporarySignedRoute('unsubscribe', now()->addHours(24), ['user' => $id]);

Route::get('/unsubscribe/{user}', ...)->middleware('signed');

Groups and Resources

everyday

Share a prefix, middleware, name and controller

Route::middleware(['auth', 'verified'])
    ->prefix('admin')
    ->name('admin.')
    ->group(function () {
        Route::get('/dashboard', DashboardController::class)->name('dashboard');
        Route::resource('products', ProductController::class);
    });

Route::controller(OrderController::class)->group(function () {
    Route::get('/orders', 'index');
    Route::post('/orders', 'store');
});

One line, seven routes

Route::resource('products', ProductController::class);

Route::resource('products', ProductController::class)->only(['index', 'show']);
Route::resource('products', ProductController::class)->except(['destroy']);
Route::apiResource('products', ProductController::class);   // no create or edit
Route::resource('posts.comments', CommentController::class)->shallow();

Subdomains and fallbacks

Route::domain('{tenant}.shop.test')->group(function () {
    Route::get('/', fn (string $tenant) => ...);
});

Route::fallback(fn () => response()->view('errors.404', [], 404));

Route caching in production, controllers only

php artisan route:cache     # closures are not allowed
php artisan route:clear
05

Controllers

resource, invokable, injection

A Resource Controller

core

The seven methods a resource route expects

namespace App\Http\Controllers;

use App\Http\Requests\{StoreProductRequest, UpdateProductRequest};
use App\Models\Product;

class ProductController extends Controller
{
    public function index()
    {
        return view('products.index', ['products' => Product::latest()->paginate(20)]);
    }

    public function create()
    {
        return view('products.create');
    }

    public function store(StoreProductRequest $request)
    {
        $product = Product::create($request->validated());
        return to_route('products.show', $product)->with('status', 'Product created.');
    }

    public function show(Product $product)
    {
        return view('products.show', compact('product'));
    }

    public function edit(Product $product)
    {
        return view('products.edit', compact('product'));
    }

    public function update(UpdateProductRequest $request, Product $product)
    {
        $product->update($request->validated());
        return back()->with('status', 'Saved.');
    }

    public function destroy(Product $product)
    {
        $product->delete();
        return to_route('products.index');
    }
}

Other Shapes

everyday

Single action controller

php artisan make:controller DashboardController --invokable

class DashboardController extends Controller
{
    public function __invoke(Request $request)
    {
        return view('dashboard', ['stats' => Stats::for($request->user())]);
    }
}

Route::get('/dashboard', DashboardController::class);

API controller, no create or edit views

php artisan make:controller Api/ProductController --api --model=Product

Dependencies arrive through the constructor or the method

class OrderController extends Controller
{
    public function __construct(private readonly PaymentGateway $gateway) {}

    public function store(StoreOrderRequest $request, Cart $cart)
    {
        $charge = $this->gateway->charge($cart->total());
    }
}

Middleware on Controllers

L11+advanced

Implement HasMiddleware instead of calling $this->middleware in the constructor

use Illuminate\Routing\Controllers\{HasMiddleware, Middleware};

class ProductController extends Controller implements HasMiddleware
{
    public static function middleware(): array
    {
        return [
            'auth',
            new Middleware('can:manage-products', except: ['index', 'show']),
            new Middleware('throttle:10,1', only: ['store']),
        ];
    }
}

Laravel 13: middleware and authorization as attributes

use Illuminate\Routing\Attributes\Controllers\{Middleware, Authorize};

#[Middleware('auth')]
class CommentController extends Controller
{
    #[Middleware('subscribed')]
    #[Authorize('create', [Comment::class, 'post'])]
    public function store(Post $post) { }

    #[Authorize('update', 'comment')]
    public function update(Comment $comment) { }
}

Authorise the whole resource against a policy in one call

Route::resource('products', ProductController::class)
    ->middleware('can:viewAny,App\Models\Product');

// or per action inside the controller
$this->authorize('update', $product);
Gate::authorize('update', $product);
06

Laravel Middleware

writing, registering, ordering

Writing Middleware

everyday

Before and after the controller

php artisan make:middleware EnsureIsAdmin

class EnsureIsAdmin
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->is_admin) {
            abort(403);                     // runs before the controller
        }

        $response = $next($request);        // the controller runs here

        $response->headers->set('X-Admin', '1');   // runs after
        return $response;
    }
}

Parameters after a colon

public function handle(Request $request, Closure $next, string $role): Response
{
    if (! $request->user()->hasRole($role)) abort(403);
    return $next($request);
}

Route::get('/reports', ...)->middleware('role:manager');

Terminable, runs after the response is sent to the browser

public function terminate(Request $request, Response $response): void
{
    Log::info('served', ['path' => $request->path(), 'ms' => $this->elapsed]);
}

Registering in bootstrap/app.php

L11+everyday

Aliases, groups and the global stack

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'admin' => EnsureIsAdmin::class,
            'role'  => EnsureHasRole::class,
        ]);

        $middleware->append(LogRequests::class);          // every request
        $middleware->web(append: [TrackVisits::class]);   // web group only
        $middleware->api(prepend: [ForceJson::class]);    // api group only
    })
    ->create();

Tweak the built ins without replacing them

$middleware->validateCsrfTokens(except: ['stripe/webhook']);
$middleware->trustProxies(at: '*');
$middleware->redirectGuestsTo('/login');
$middleware->redirectUsersTo('/dashboard');
$middleware->statefulApi();          // Sanctum SPA sessions
$middleware->throttleApi('120,1');

Apply to routes

Route::get('/admin', ...)->middleware(['auth', 'admin']);
Route::get('/public', ...)->withoutMiddleware('throttle');
Route::middleware('auth')->group(fn () => ...);

Built in Middleware

everyday

The aliases you will use most

AliasDoes
authrequires a signed in user, redirects guests
auth:sanctumtoken or SPA session authentication
guestonly for signed out users, redirects the rest
verifiedrequires a verified email address
can:ability,modelauthorises against a gate or policy
throttle:60,160 requests per minute, or a named limiter
signedvalidates a signed URL
password.confirmre-prompts for the password on sensitive pages
precognitiveenables Precognition live validation
cache.headers:public;max_age=3600sets HTTP cache headers

Named rate limiters, defined in AppServiceProvider::boot

RateLimiter::for('uploads', function (Request $request) {
    return $request->user()?->isPro()
        ? Limit::none()
        : Limit::perMinute(5)->by($request->ip());
});

Route::post('/upload', ...)->middleware('throttle:uploads');
07

Requests & Validation Rules

input, files, rules, form requests

Reading the Request

core

Input from the query string, the body or JSON, all the same call

$request->input('email');
$request->input('user.name');            // dot notation into arrays
$request->input('page', 1);              // default
$request->all();
$request->only(['name', 'email']);
$request->except(['password']);
$request->string('name')->trim()->title();
$request->integer('page');  $request->boolean('remember');  $request->date('from');

Presence checks

$request->has('email');            // key exists, may be empty
$request->filled('email');         // exists and not empty
$request->missing('email');
$request->whenFilled('coupon', fn ($c) => $cart->apply($c));

Everything else on the request

$request->method();   $request->isMethod('post');
$request->path();     $request->url();     $request->fullUrl();
$request->is('admin/*');   $request->routeIs('orders.*');
$request->header('X-Request-Id');
$request->bearerToken();
$request->ip();       $request->userAgent();
$request->cookie('theme');
$request->user();     $request->route('product');
$request->expectsJson();   $request->wantsJson();

Uploaded files

if ($request->hasFile('avatar') && $request->file('avatar')->isValid()) {
    $path = $request->file('avatar')->store('avatars', 'public');
    $name = $request->file('avatar')->getClientOriginalName();
    $mime = $request->file('avatar')->getMimeType();
}

Validating

core

Inline, the failure redirects back with errors automatically

$validated = $request->validate([
    'title'   => ['required', 'string', 'max:120'],
    'email'   => ['required', 'email', 'unique:users,email'],
    'price'   => ['required', 'numeric', 'min:0'],
    'tags'    => ['array', 'max:5'],
    'tags.*'  => ['string', 'distinct'],
    'starts'  => ['nullable', 'date', 'after:today'],
]);

Product::create($validated);

A form request keeps the controller clean

php artisan make:request StoreProductRequest

class StoreProductRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Product::class);
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'max:120'],
            'sku'   => ['required', Rule::unique('products')->ignore($this->product)],
        ];
    }

    public function messages(): array
    {
        return ['sku.unique' => 'That SKU is already taken.'];
    }
}

public function store(StoreProductRequest $request)
{
    Product::create($request->validated());   // only runs if it passed
}

Read the validated data safely

$request->validated();
$request->safe()->only(['title', 'sku']);
$request->safe()->except('tags');
$request->safe()->merge(['user_id' => auth()->id()]);

Show the errors in Blade

@error('email')
    <p class="text-red-600">{{ $message }}</p>
@enderror

<input name="email" value="{{ old('email') }}" @class(['is-invalid' => $errors->has('email')])>

@if ($errors->any())
    <ul>@foreach ($errors->all() as $error) <li>{{ $error }}</li> @endforeach</ul>
@endif

Validation Rules Reference

everyday

The rules that cover almost every form

GroupRules
Presencerequired, nullable, sometimes, present, filled, prohibited, required_if:field,value, required_with:a,b, required_unless, missing
Typestring, integer, numeric, boolean, array, json, date, file, image, uuid, ulid, email, url, active_url, ip, timezone
Sizemin:3, max:255, size:10, between:1,5, digits:4, digits_between:4,6, gt:field, lt:field, multiple_of:5
Formatalpha, alpha_num, alpha_dash, regex:/^[a-z]+$/, starts_with:a,b, ends_with:.pdf, lowercase, uppercase, hex_color, ascii, decimal:2
Datesdate, date_format:Y-m-d, after:today, after_or_equal:start, before:tomorrow, date_equals
Databaseexists:users,id, unique:users,email, Rule::exists('t')->where(...), Rule::unique('t')->ignore($id)
Setsin:a,b,c, not_in:x, Rule::in([...]), Rule::enum(Status::class), distinct, in_array:field.*
Filesfile, image, mimes:jpg,png, mimetypes:image/*, extensions:pdf, max:2048 (KB), dimensions:min_width=100, File::image()->max('2mb')
Matchingconfirmed (needs field_confirmation), same:field, different:field, current_password, accepted, declined
Arraysarray:key1,key2, list, min:1, tags.* for each element, tags.*.id for nested
Controlbail (stop at first failure), exclude, exclude_if, exclude_unless, sometimes

Password rules, built in and configurable

use Illuminate\Validation\Rules\Password;

'password' => ['required', 'confirmed', Password::min(12)->letters()->numbers()->uncompromised()],

// set the default once in AppServiceProvider::boot
Password::defaults(fn () => Password::min(12)->uncompromised());
'password' => ['required', Password::defaults()],

Custom Rules

advanced

A closure for a one off

'coupon' => ['nullable', function (string $attribute, mixed $value, Closure $fail) {
    if (! Coupon::isActive($value)) {
        $fail("The {$attribute} has expired.");
    }
}],

A rule class you can reuse and test

php artisan make:rule ValidIban

class ValidIban implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! Iban::check($value)) {
            $fail('validation.iban')->translate();
        }
    }
}

'iban' => ['required', new ValidIban],

Conditional rules and the Validator facade

$validator = Validator::make($data, $rules);

$validator->sometimes('vat_number', 'required', fn ($input) => $input->country === 'RO');

if ($validator->fails()) {
    return back()->withErrors($validator)->withInput();
}
$clean = $validator->validated();
08

Responses

views, JSON, redirects, files

Return Types

core

Laravel turns whatever you return into an HTTP response

return 'plain text';                       // 200, text/html
return ['ok' => true];                      // 200, JSON
return $product;                            // model to JSON, hidden fields removed
return Product::paginate(20);               // JSON with pagination meta
return view('products.show', compact('product'));
return response('Created', 201);
return response()->noContent();             // 204

JSON with an explicit status and headers

return response()->json(['id' => $order->id], 201)
    ->header('Location', route('orders.show', $order))
    ->withHeaders(['X-Request-Id' => $id]);

Cookies

return response('ok')->cookie('theme', 'dark', minutes: 60 * 24 * 30);
Cookie::queue('theme', 'dark', 43200);         // attach to the outgoing response
return response('ok')->withoutCookie('theme');

Redirects

core

Redirect after a successful POST, with a flash message

return redirect('/dashboard');
return redirect()->route('orders.show', $order);
return to_route('orders.show', $order);
return back();
return back()->withInput();                            // repopulate the form
return to_route('orders.index')->with('status', 'Order placed.');

Read the flash in Blade

@session('status')
    <div class="alert">{{ $value }}</div>
@endsession

Off site and intended

return redirect()->away('https://stripe.com/checkout/...');
return redirect()->intended('/dashboard');    // where the guest wanted to go
return redirect()->action([OrderController::class, 'index']);

Files, Streams, Errors

everyday

Download and inline display

return response()->download($path, 'invoice.pdf');
return response()->file($path);                 // display inline
return Storage::download('reports/q3.csv');
return Storage::disk('s3')->response('docs/guide.pdf');

Stream a large export without loading it all in memory

return response()->streamDownload(function () {
    $out = fopen('php://output', 'w');
    fputcsv($out, ['id', 'email']);
    User::lazy()->each(fn ($u) => fputcsv($out, [$u->id, $u->email]));
    fclose($out);
}, 'users.csv');

return response()->streamJson(['users' => User::cursor()]);

Abort with an HTTP error

abort(404);
abort(403, 'You do not own this order.');
abort_if($order->user_id !== auth()->id(), 403);
abort_unless($request->user()->isAdmin(), 403);
throw new NotFoundHttpException;

Response macros for repeated shapes

// AppServiceProvider::boot
Response::macro('success', fn ($data = null, $status = 200) =>
    response()->json(['ok' => true, 'data' => $data], $status));

return response()->success($order, 201);
09

Blade Templates

echo, control flow, layouts, components

Echo and Control Flow

core

Escaped by default, unescaped on purpose

{{ $user->name }}                 {{-- escaped --}}
{!! $post->rendered_html !!}      {{-- raw, trusted only --}}
{{ $user->nickname ?? 'guest' }}
{{ $price * 1.19 }}
@{{ not touched, for Vue or Alpine }}

Conditionals

@if ($order->isPaid())
    Paid
@elseif ($order->isPending())
    Pending
@else
    Cancelled
@endif

@unless ($user->verified) Please verify your email. @endunless
@isset($discount) ... @endisset
@empty($items) No items. @endempty

@auth Signed in @endauth
@guest Please log in @endguest
@can('update', $post) <a href="...">Edit</a> @endcan
@env('local') Debug bar on @endenv
@production ... @endproduction

Loops, and the $loop helper

@foreach ($orders as $order)
    <tr @class(['bg-gray-50' => $loop->even])>
        <td>{{ $loop->iteration }}</td>
        <td>{{ $order->number }}</td>
    </tr>
@endforeach

@forelse ($orders as $order)
    ...
@empty
    <p>No orders yet.</p>
@endforelse

@for ($i = 0; $i < 5; $i++) ... @endfor
@while (true) ... @endwhile

{{-- $loop->first, last, index, count, remaining, depth, parent --}}
@continue($order->hidden)   @break($loop->index > 9)

Switch, PHP blocks, comments

@switch($status)
    @case('paid') Paid @break
    @case('refunded') Refunded @break
    @default Unknown
@endswitch

@php $total = $items->sum('price'); @endphp
{{-- this comment never reaches the browser --}}

Forms and Attributes

core

CSRF and method spoofing, both required

<form method="POST" action="{{ route('products.update', $product) }}">
    @csrf
    @method('PUT')
    <input name="title" value="{{ old('title', $product->title) }}">
    <button>Save</button>
</form>

Conditional classes, styles and attributes

<div @class(['p-4', 'font-bold' => $isActive, 'text-red-500' => $hasError])>
<div @style(['color: red' => $hasError])>
<input type="checkbox" @checked(old('active', $user->active))>
<option @selected($plan === 'pro')>Pro</option>
<button @disabled($errors->any())>Save</button>
<input @required($isAdmin) @readonly($locked)>

Pass data to JavaScript

<script>
    const products = @json($products);
    const user = {{ Js::from($user) }};
</script>
<div x-data="{ open: @js($open) }">

Layouts

everyday

The modern way, a layout component with slots

{{-- resources/views/components/layout.blade.php --}}
<!DOCTYPE html>
<html>
<head>
    <title>{{ $title ?? 'Shop' }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('head')
</head>
<body>
    <x-nav />
    <main>{{ $slot }}</main>
    @stack('scripts')
</body>
</html>

{{-- any page --}}
<x-layout>
    <x-slot:title>Products</x-slot>
    <h1>Products</h1>
    @push('scripts') <script src="/charts.js"></script> @endpush
</x-layout>

The classic way, still everywhere

{{-- layouts/app.blade.php --}}
<title>@yield('title', 'Shop')</title>
<body>
    @include('partials.nav', ['active' => 'home'])
    @yield('content')
</body>

{{-- a page --}}
@extends('layouts.app')
@section('title', 'Products')
@section('content')
    <h1>Products</h1>
@endsection

Includes with guards

@include('partials.alert', ['type' => 'warning'])
@includeIf('partials.optional')
@includeWhen($user->isAdmin(), 'partials.admin-bar')
@each('partials.item', $items, 'item', 'partials.empty')
@once <script src="/vendor/chart.js"></script> @endonce

Components

everyday

Anonymous component, one Blade file, no class

{{-- resources/views/components/button.blade.php --}}
@props(['variant' => 'primary', 'href' => null])

@php $base = 'inline-flex rounded px-4 py-2 font-medium'; @endphp

@if ($href)
    <a href="{{ $href }}" {{ $attributes->merge(['class' => "$base btn-$variant"]) }}>{{ $slot }}</a>
@else
    <button {{ $attributes->merge(['class' => "$base btn-$variant"]) }}>{{ $slot }}</button>
@endif

{{-- usage --}}
<x-button variant="danger" class="ml-2" wire:click="delete">Delete</x-button>
<x-button :href="route('home')">Home</x-button>

Class based component, when you need logic or dependencies

php artisan make:component Alert

class Alert extends Component
{
    public function __construct(
        public string $type = 'info',
        public ?string $message = null,
    ) {}

    public function icon(): string
    {
        return match ($this->type) { 'error' => 'x-circle', 'success' => 'check', default => 'info' };
    }

    public function render(): View
    {
        return view('components.alert');
    }
}

{{-- components/alert.blade.php --}}
<div {{ $attributes->class(['alert', "alert-$type"]) }}>
    <x-icon :name="$icon()" />
    {{ $message ?? $slot }}
</div>

Named slots, nested folders, dynamic components

<x-card>
    <x-slot:header class="border-b">Title</x-slot>
    Body text
    <x-slot name="footer">Footer</x-slot>
</x-card>

<x-forms.input name="email" />            {{-- components/forms/input.blade.php --}}
<x-dynamic-component :component="$type" />
{{ $header->attributes->merge(['class' => 'p-4']) }}
@aware(['size'])                          {{-- read a prop from the parent component --}}
10

Livewire

interactive UI without writing JavaScript

Components

Livewire 4everyday

A single file component, PHP class and Blade in one file

php artisan make:livewire post.create
{{-- resources/views/components/post/⚡create.blade.php --}}

<?php

use Livewire\Component;
use App\Models\Post;

new class extends Component
{
    public string $title = '';
    public string $body = '';

    public function save(): void
    {
        $this->validate(['title' => 'required|max:120', 'body' => 'required']);
        Post::create(['title' => $this->title, 'body' => $this->body]);
        $this->reset();
        session()->flash('status', 'Saved.');
    }
};
?>

<form wire:submit="save">
    <input wire:model="title" placeholder="Title">
    @error('title') <span>{{ $message }}</span> @enderror
    <textarea wire:model="body"></textarea>
    <button type="submit">Save</button>
</form>

Render it, pass it data, or make it a whole page

<livewire:post.create />
<livewire:post.show :post="$post" />
<livewire:admin::users-table />                    {{-- a namespaced folder --}}
<livewire:post.create lazy />                       {{-- render after page load --}}

// full page component with route model binding
Route::livewire('/posts/{post}', 'pages::post.show');

public Post $post;                                  // bound from the route
public function mount(Post $post): void { $this->post = $post; }

Multi file and class based forms

php artisan make:livewire post.create --mfc     # create.php + create.blade.php + optional js, css, test
php artisan make:livewire post.create --class   # app/Livewire/Post/Create.php + a view
php artisan livewire:convert post.create --mfc

// app/Livewire/Post/Create.php
class Create extends Component
{
    public function render(): View
    {
        return view('livewire.post.create')->layout('layouts.app');
    }
}

Layout requirements

<head>
    @livewireStyles
</head>
<body>
    {{ $slot }}
    @livewireScripts
</body>
{{-- Livewire injects these automatically if you leave them out --}}

wire: Directives

everyday

Binding, with the modifiers that control when the server hears about it

<input wire:model="search">                     {{-- on submit or action --}}
<input wire:model.live="search">                {{-- every keystroke, debounced 150ms --}}
<input wire:model.live.debounce.500ms="search">
<input wire:model.blur="email">                 {{-- when the field loses focus --}}
<input type="checkbox" wire:model="terms">
<select wire:model="plan"> ... </select>
<input type="file" wire:model="avatar">         {{-- uploads via a temporary file --}}

Actions and events

<button wire:click="save">Save</button>
<button wire:click="remove({{ $post->id }})">Delete</button>
<button wire:click="remove({{ $post->id }})" wire:confirm="Really delete?">Delete</button>
<form wire:submit="save">                       {{-- preventDefault built in --}}
<input wire:keydown.enter="search">
<div wire:click.outside="close">
<button wire:click.prevent="...">   wire:click.stop   wire:click.debounce.300ms

Loading states, polling, navigation

<button wire:click="save" wire:loading.attr="disabled">Save</button>
<span wire:loading wire:target="save">Saving...</span>
<div wire:loading.remove>content hidden while loading</div>
<div wire:loading.class="opacity-50">

<div wire:poll.5s>{{ $this->unreadCount }}</div>
<div wire:poll.visible>only while on screen</div>

<a href="/posts" wire:navigate>Posts</a>        {{-- SPA style page swap --}}
<a href="/posts" wire:navigate.hover>Posts</a>  {{-- prefetch on hover --}}

Loops need a key, and Alpine is already there

@foreach ($posts as $post)
    <li wire:key="post-{{ $post->id }}">{{ $post->title }}</li>
@endforeach

<div x-data="{ open: false }">
    <button x-on:click="open = ! open">Toggle</button>
    <div x-show="open">Alpine ships with Livewire</div>
    <button x-on:click="$wire.save()">Call a Livewire action from Alpine</button>
</div>

Attributes, Events, Lifecycle

advanced

Property attributes

use Livewire\Attributes\{Validate, Url, Locked, Computed, On};

#[Validate('required|max:120')]
public string $title = '';

#[Url]                                    // ?search=... stays in the address bar
public string $search = '';

#[Locked]                                 // the browser cannot change it
public int $postId;

#[Computed]                               // memoised for the request, $this->posts in Blade
public function posts()
{
    return Post::where('title', 'like', "%{$this->search}%")->latest()->get();
}

Events between components

// in the sender
$this->dispatch('item-added', id: $product->id);
$this->dispatch('item-added')->to(CartIcon::class);
$this->dispatch('item-added')->self();

// in the listener
#[On('item-added')]
public function refreshCount(int $id): void { $this->count++; }

// or in the browser
<div x-on:item-added.window="alert('added')">

Lifecycle hooks

public function mount(Post $post) {}         // once, on first load
public function hydrate() {}                 // every request, after state is restored
public function updated(string $name, mixed $value) {}
public function updatedSearch(string $value) { $this->resetPage(); }
public function rendering() {}   public function rendered() {}
public function dehydrate() {}

Form objects, pagination, redirects

// app/Livewire/Forms/PostForm.php
class PostForm extends Form
{
    #[Validate('required|max:120')] public string $title = '';
    public function store(): void { Post::create($this->all()); $this->reset(); }
}
public PostForm $form;                        // <input wire:model="form.title">

use WithPagination;   $posts = Post::paginate(10);   {{ $posts->links() }}
return $this->redirect('/posts', navigate: true);
$this->js("alert('done')");

Testing a component

Livewire::test('post.create')
    ->set('title', 'Hello')
    ->set('body', 'World')
    ->call('save')
    ->assertHasNoErrors()
    ->assertSee('Saved.');

Livewire::actingAs($user)->test(Dashboard::class)->assertStatus(200);
11

Inertia

React or Vue pages fed by controllers

Server Side

Inertia 3everyday

A controller returns a page component and its props

use Inertia\Inertia;

public function index(Request $request)
{
    return Inertia::render('Orders/Index', [
        'orders'  => OrderResource::collection(Order::with('user')->latest()->paginate(20)),
        'filters' => $request->only(['status', 'q']),
        'can'     => ['create' => $request->user()->can('create', Order::class)],
    ]);
}

// the helper form
return inertia('Orders/Show', ['order' => $order]);

Redirects and validation errors need nothing special

public function store(StoreOrderRequest $request)
{
    $order = Order::create($request->validated());
    return to_route('orders.show', $order)->with('status', 'Order placed.');
}
// a 422 becomes props.errors on the page automatically
// a flash message is read through shared data

Shared data, available on every page

// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth'  => ['user' => $request->user()?->only('id', 'name', 'email')],
        'flash' => ['status' => fn () => $request->session()->get('status')],
    ];
}

// anywhere else
Inertia::share('appName', config('app.name'));

Deferred, optional and merged props

return Inertia::render('Dashboard', [
    'orders'   => Order::latest()->take(20)->get(),              // sent immediately
    'stats'    => Inertia::defer(fn () => Stats::build()),       // loaded after render
    'charts'   => Inertia::defer(fn () => Charts::all(), 'slow'), // grouped, one request
    'export'   => Inertia::optional(fn () => Export::build()),   // only on partial reload
    'feed'     => Inertia::merge(fn () => Feed::page($page)),    // appended for infinite scroll
    'tenant'   => Inertia::always($tenant),                      // even on partial reloads
]);

External redirects, SSR, the root view

return Inertia::location('https://checkout.stripe.com/...');   // full browser redirect
Inertia::setRootView('app');                                     // resources/views/app.blade.php
php artisan inertia:start-ssr                                    // server side rendering

Client Side

everyday

A page component receives the props, React

// resources/js/pages/Orders/Index.tsx
import { Head, Link, usePage } from '@inertiajs/react';

export default function Index({ orders, filters }: Props) {
    const { auth, flash } = usePage().props;
    return (
        <>
            <Head title="Orders" />
            {flash.status && <p>{flash.status}</p>}
            {orders.data.map((o) => (
                <Link key={o.id} href={route('orders.show', o.id)}>{o.number}</Link>
            ))}
        </>
    );
}

The same page in Vue

<script setup lang="ts">
import { Head, Link, usePage } from '@inertiajs/vue3';
defineProps<{ orders: Paginated<Order>; filters: Filters }>();
const page = usePage();
</script>

<template>
    <Head title="Orders" />
    <Link v-for="o in orders.data" :key="o.id" :href="route('orders.show', o.id)">{{ o.number }}</Link>
</template>

Forms with useForm

import { useForm } from '@inertiajs/react';

const form = useForm({ sku: '', qty: 1 });

const submit = (e) => {
    e.preventDefault();
    form.post(route('orders.store'), { onSuccess: () => form.reset() });
};

<input value={form.data.sku} onChange={(e) => form.setData('sku', e.target.value)} />
{form.errors.sku && <span>{form.errors.sku}</span>}
<button disabled={form.processing}>Place order</button>

Navigation and partial reloads

import { router } from '@inertiajs/react';

router.visit('/orders');
router.get('/orders', { status: 'paid' }, { preserveState: true, preserveScroll: true });
router.post('/orders', data);
router.reload({ only: ['orders'] });          // partial reload, only these props
router.reload({ except: ['charts'] });

<Link href="/orders" method="delete" as="button">Delete</Link>
<Link href="/orders" prefetch>Orders</Link>

Deferred props on the client

import { Deferred } from '@inertiajs/react';

<Deferred data="stats" fallback={<Spinner />}>
    <StatsPanel />
</Deferred>

Testing an Inertia response

$this->get('/orders')->assertInertia(fn (Assert $page) => $page
    ->component('Orders/Index')
    ->has('orders.data', 20)
    ->where('filters.status', null)
    ->missing('secret'));
12

Query Builder

DB::table, joins, aggregates, transactions

Selecting

core

Rows, one row, one column, one value

use Illuminate\Support\Facades\DB;

$rows  = DB::table('orders')->where('status', 'paid')->get();      // Collection of stdClass
$row   = DB::table('orders')->where('id', 5)->first();
$row   = DB::table('orders')->find(5);
$total = DB::table('orders')->where('id', 5)->value('total');
$ids   = DB::table('orders')->pluck('id');
$map   = DB::table('users')->pluck('email', 'id');                  // id => email
$yes   = DB::table('orders')->where('number', $n)->exists();

Where clauses

->where('total', '>', 100)
->where('status', 'paid')                  // = is implied
->where([['status', 'paid'], ['total', '>', 100]])
->orWhere('vip', true)
->whereIn('id', [1, 2, 3])   ->whereNotIn(...)
->whereBetween('total', [10, 100])
->whereNull('shipped_at')   ->whereNotNull(...)
->whereDate('created_at', today())   ->whereMonth(...)   ->whereYear(...)
->whereLike('email', '%@example.com')
->whereColumn('updated_at', '>', 'created_at')
->whereExists(fn ($q) => $q->select(DB::raw(1))->from('refunds')->whereColumn('refunds.order_id', 'orders.id'))
->whereJsonContains('tags', 'sale')

Group OR conditions in a closure, or the SQL will not mean what you think

DB::table('orders')
    ->where('status', 'paid')
    ->where(function ($q) {
        $q->where('total', '>', 1000)->orWhere('vip', true);
    })
    ->get();
// WHERE status = 'paid' AND (total > 1000 OR vip = 1)

Optional filters with when and unless, no if statements

$orders = DB::table('orders')
    ->when($request->status, fn ($q, $status) => $q->where('status', $status))
    ->when($request->from, fn ($q, $from) => $q->whereDate('created_at', '>=', $from))
    ->when($request->sort, fn ($q, $sort) => $q->orderBy($sort), fn ($q) => $q->latest())
    ->unless($request->user()->isAdmin(), fn ($q) => $q->where('user_id', $request->user()->id))
    ->paginate(25);

Ordering, limiting, distinct

->orderBy('created_at', 'desc')   ->latest()   ->oldest('shipped_at')
->orderByRaw('FIELD(status, "paid", "pending")')
->inRandomOrder()
->limit(10)->offset(20)   ->skip(20)->take(10)
->distinct()
->select('id', 'total')   ->addSelect('status')
->selectRaw('SUM(total) as revenue, COUNT(*) as n')

Joins, Groups, Aggregates

everyday

Joins

DB::table('orders')
    ->join('users', 'users.id', '=', 'orders.user_id')
    ->leftJoin('coupons', 'coupons.id', '=', 'orders.coupon_id')
    ->select('orders.*', 'users.email', 'coupons.code')
    ->get();

->join('users', function (JoinClause $join) {
    $join->on('users.id', '=', 'orders.user_id')->where('users.active', true);
})
->joinSub($subquery, 'stats', 'stats.user_id', '=', 'users.id')

Group and aggregate

DB::table('orders')
    ->selectRaw('status, COUNT(*) as n, SUM(total) as revenue')
    ->groupBy('status')
    ->having('n', '>', 10)
    ->get();

DB::table('orders')->count();
DB::table('orders')->sum('total');   ->avg('total');   ->max('total');   ->min('total');

Subqueries and unions

$latest = DB::table('orders')
    ->select('total')->whereColumn('user_id', 'users.id')->latest()->limit(1);

DB::table('users')->addSelect(['last_total' => $latest])->get();

$a = DB::table('users')->where('role', 'admin');
DB::table('users')->where('role', 'editor')->union($a)->get();

Writing and Transactions

everyday

Insert, update, delete

DB::table('tags')->insert(['name' => 'sale']);
DB::table('tags')->insert([['name' => 'a'], ['name' => 'b']]);
$id = DB::table('tags')->insertGetId(['name' => 'new']);
DB::table('tags')->insertOrIgnore([...]);

DB::table('orders')->where('id', 5)->update(['status' => 'shipped']);
DB::table('orders')->where('id', 5)->increment('views');   ->decrement('stock', 3);
DB::table('orders')->where('id', 5)->delete();
DB::table('logs')->truncate();

Upsert, insert or update on a unique key

DB::table('prices')->upsert(
    [['sku' => 'A1', 'price' => 10], ['sku' => 'B2', 'price' => 20]],
    uniqueBy: ['sku'],
    update: ['price']
);

DB::table('settings')->updateOrInsert(['key' => 'theme'], ['value' => 'dark']);

Transactions, closure form rolls back on any exception

DB::transaction(function () use ($order) {
    $order->update(['status' => 'paid']);
    $order->user->decrement('credit', $order->total);
    Payment::create([...]);
}, attempts: 3);   // retry on deadlock

// manual form
DB::beginTransaction();
try { ...; DB::commit(); } catch (Throwable $e) { DB::rollBack(); throw $e; }

Raw SQL, always with bindings

DB::select('SELECT * FROM orders WHERE total > ?', [100]);
DB::select('SELECT * FROM orders WHERE status = :s', ['s' => 'paid']);
DB::statement('ALTER TABLE orders ADD INDEX idx_status (status)');
DB::unprepared('...');                 // no bindings, dangerous

DB::table('orders')->whereRaw('total > ? AND YEAR(created_at) = ?', [100, 2026]);

Large Results and Debugging

advanced

Process a huge table without running out of memory

DB::table('logs')->orderBy('id')->chunk(1000, function ($rows) {
    foreach ($rows as $row) { ... }
});

DB::table('logs')->where('old', true)->chunkById(500, fn ($rows) => ...);

foreach (DB::table('logs')->lazy() as $row) { ... }
foreach (DB::table('logs')->cursor() as $row) { ... }

Pagination

$orders = DB::table('orders')->paginate(25);          // with total count
$orders = DB::table('orders')->simplePaginate(25);    // next and previous only
$orders = DB::table('orders')->cursorPaginate(25);    // fast on big tables

{{ $orders->links() }}
{{ $orders->withQueryString()->links() }}

See the SQL before it runs

$q = DB::table('orders')->where('status', 'paid')->where('total', '>', 100)->latest()->limit(10);

$q->toSql();
$q->toRawSql();     // bindings inlined
$q->dd();           // dump and die
$q->dumpRawSql();

DB::enableQueryLog();  ...;  dd(DB::getQueryLog());

Several connections

DB::connection('reporting')->table('sales')->get();
DB::connection('reporting')->getPdo();
DB::table('orders')->useWritePdo()->get();   // read replicas configured
13

Laravel Migrations

schema, columns, keys, rollbacks

Create and Alter

core

A create migration, anonymous class since Laravel 9

php artisan make:migration create_products_table

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->decimal('price', 10, 2);
            $table->unsignedInteger('stock')->default(0);
            $table->boolean('active')->default(true);
            $table->json('meta')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

Alter an existing table

php artisan make:migration add_sku_to_products_table --table=products

Schema::table('products', function (Blueprint $table) {
    $table->string('sku', 40)->after('slug')->nullable();
    $table->index(['active', 'created_at']);
});

// down
Schema::table('products', function (Blueprint $table) {
    $table->dropIndex(['active', 'created_at']);
    $table->dropColumn('sku');
});

Change, rename, drop

$table->string('name', 120)->nullable()->change();
$table->renameColumn('title', 'name');
$table->dropColumn(['legacy_a', 'legacy_b']);
$table->dropSoftDeletes();   $table->dropTimestamps();
Schema::rename('old', 'new');
Schema::hasTable('products');   Schema::hasColumn('products', 'sku');

Column Types and Modifiers

core

The ones that cover nearly every table

MethodColumn
id(), ulid(), uuid()auto increment big int, or a string primary key
foreignId('user_id'), foreignUlid, foreignUuidunsigned big int sized to match id()
foreignIdFor(User::class)derives the column name from the model
string('name', 100), char, text, mediumText, longTextVARCHAR and text types
integer, unsignedInteger, bigInteger, tinyInteger, smallIntegerintegers
decimal('price', 10, 2), float, double, unsignedDecimalnumbers, decimal for money
booleantinyint(1)
date, dateTime, dateTimeTz, time, timestamp, yeartemporal types
timestamps(), softDeletes(), rememberToken()created_at and updated_at, deleted_at, remember_token
json, jsonbJSON, jsonb is Postgres native
enum('status', ['a', 'b']), setenumerations
morphs('commentable'), nullableMorphs, ulidMorphsthe two columns for a polymorphic relation
ipAddress, macAddress, geometry, pointspecialised types
binary, vectorblobs and, in recent versions, vector embeddings

Modifiers, chained after the type

->nullable()   ->default('draft')   ->default(DB::raw('CURRENT_TIMESTAMP'))
->unique()   ->index()   ->primary()   ->fulltext()
->unsigned()   ->autoIncrement()   ->useCurrent()   ->useCurrentOnUpdate()
->after('name')   ->first()   ->comment('internal note')
->charset('utf8mb4')   ->collation('utf8mb4_unicode_ci')
->storedAs('price * quantity')   ->virtualAs(...)   ->invisible()

Keys and Indexes

everyday

Foreign keys, the short form and what it does

$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignIdFor(User::class)->constrained()->restrictOnDelete();

// long form
$table->unsignedBigInteger('user_id');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');

$table->dropForeign(['user_id']);
$table->dropConstrainedForeignId('user_id');   // drops the column too

Indexes

$table->index('status');
$table->index(['user_id', 'created_at'], 'orders_user_created_idx');
$table->unique(['tenant_id', 'slug']);
$table->fullText(['title', 'body']);
$table->dropIndex('orders_user_created_idx');
$table->dropUnique(['tenant_id', 'slug']);

Disable foreign key checks around a risky change

Schema::disableForeignKeyConstraints();
Schema::dropIfExists('legacy');
Schema::enableForeignKeyConstraints();

Schema::withoutForeignKeyConstraints(fn () => Schema::drop('legacy'));

Running Migrations

everyday

Forward, back, and the two that destroy data

php artisan migrate
php artisan migrate --force              # required in production
php artisan migrate --pretend            # print the SQL only
php artisan migrate:rollback             # last batch
php artisan migrate:rollback --step=2
php artisan migrate:reset                # roll back everything
php artisan migrate:refresh --seed       # reset then migrate
php artisan migrate:fresh --seed         # drop all tables then migrate

Squash a long history into one schema file

php artisan schema:dump
php artisan schema:dump --prune     # also delete the migration files

Migrations that cannot run in a transaction, or on one connection

return new class extends Migration
{
    public $withinTransaction = false;
    protected $connection = 'reporting';
    ...
};
14

Eloquent Models

conventions, casts, scopes, CRUD

Defining a Model

core

Conventions, and every override you might need (casts() needs Laravel 11)

namespace App\Models;

use Illuminate\Database\Eloquent\{Model, SoftDeletes};
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;

class Product extends Model
{
    use HasFactory, SoftDeletes;

    // overrides, only when the convention does not fit
    protected $table = 'products';
    protected $primaryKey = 'id';
    public $incrementing = true;
    protected $keyType = 'int';
    public $timestamps = true;
    protected $connection = 'mysql';

    protected $fillable = ['name', 'slug', 'price', 'category_id'];
    protected $hidden = ['cost_price'];              // left out of JSON
    protected $appends = ['display_price'];          // computed, added to JSON
    protected $with = ['category'];                  // always eager loaded

    protected function casts(): array
    {
        return [
            'price'     => 'decimal:2',
            'active'    => 'boolean',
            'meta'      => 'array',
            'status'    => ProductStatus::class,     // backed enum
            'launch_at' => 'datetime',
        ];
    }

    protected function displayPrice(): Attribute
    {
        return Attribute::make(get: fn () => number_format($this->price, 2) . ' EUR');
    }
}

On Laravel 10 and earlier, casts are a property

protected $casts = [
    'price'  => 'decimal:2',
    'active' => 'boolean',
    'meta'   => 'array',
    'status' => ProductStatus::class,
];
// the casts() method still works on 11+ and wins if both are present

Casts worth knowing

CastGives you
integer, float, string, booleanscalar coercion on read
decimal:2a string with two decimals, safe for money
array, json, object, collectionJSON column decoded, re-encoded on save
AsArrayObject::class, AsCollection::classJSON you can mutate in place
datetime, immutable_datetime, date, timestampCarbon instances
encrypted, encrypted:arraytransparently encrypted at rest
hashedbcrypt on set, used for password
SomeEnum::classa backed enum instance
AsStringable::classa fluent Stringable

Reading

core

Every query builder method works, plus these

Product::all();
Product::find(5);                Product::find([1, 2, 3]);
Product::findOrFail(5);          // 404 in a controller
Product::findOr(5, fn () => Product::make());
Product::where('active', true)->get();
Product::where('slug', $slug)->first();      ->firstOrFail();   ->sole();
Product::firstWhere('sku', 'A1');
Product::latest()->take(5)->get();
Product::count();   Product::max('price');   Product::where(...)->exists();

Scopes, reusable query fragments

// in the model
public function scopeActive(Builder $q): void
{
    $q->where('active', true);
}

public function scopeCheaperThan(Builder $q, float $max): void
{
    $q->where('price', '<', $max);
}

Product::active()->cheaperThan(50)->get();

// Laravel 12: the #[Scope] attribute replaces the scope prefix
use Illuminate\Database\Eloquent\Attributes\Scope;

#[Scope]
protected function active(Builder $q): void { $q->where('active', true); }

#[Scope]
protected function cheaperThan(Builder $q, float $max): void { $q->where('price', '<', $max); }

Global scopes apply to every query

protected static function booted(): void
{
    static::addGlobalScope('tenant', function (Builder $q) {
        $q->where('tenant_id', tenant()->id);
    });
}

Product::withoutGlobalScope('tenant')->get();
Product::withoutGlobalScopes()->get();

Big result sets and pagination

Product::chunk(500, fn ($products) => ...);
Product::chunkById(500, fn ($products) => ...);
Product::lazy()->each(fn ($p) => ...);
foreach (Product::cursor() as $p) { }
Product::paginate(20);   Product::cursorPaginate(20);

Writing

core

Create and update, mass assignment guarded by $fillable

$product = Product::create(['name' => 'Desk', 'price' => 199]);

$product = new Product;
$product->name = 'Desk';
$product->save();

$product->update(['price' => 179]);
$product->fill(['stock' => 5])->save();
Product::where('active', false)->update(['archived_at' => now()]);   // bulk, no events

Find or create in one call

Product::firstOrCreate(['sku' => 'A1'], ['name' => 'Desk', 'price' => 199]);
Product::firstOrNew(['sku' => 'A1']);            // not saved yet
Product::updateOrCreate(['sku' => 'A1'], ['price' => 179]);
Product::upsert([...], uniqueBy: ['sku'], update: ['price']);

Delete, soft delete, restore

$product->delete();
Product::destroy(5);   Product::destroy([1, 2, 3]);
Product::where('stock', 0)->delete();

// with the SoftDeletes trait
$product->delete();            // sets deleted_at
$product->restore();
$product->forceDelete();       // really gone
Product::withTrashed()->get();
Product::onlyTrashed()->get();
$product->trashed();

State checks and helpers

$product->exists;   $product->wasRecentlyCreated;
$product->isDirty('price');   $product->isClean();   $product->wasChanged();
$product->getOriginal('price');   $product->getChanges();
$product->refresh();   $product->fresh();
$copy = $product->replicate(['slug']);
$product->touch();   $product->toArray();   $product->toJson();

Accessors, Mutators, Serialization

L9+everyday

Read and write transforms in one place

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function name(): Attribute
{
    return Attribute::make(
        get: fn (string $value) => ucfirst($value),
        set: fn (string $value) => strtolower(trim($value)),
    );
}

protected function address(): Attribute
{
    return Attribute::make(
        get: fn (mixed $value, array $attrs) => new Address($attrs['street'], $attrs['city']),
        set: fn (Address $a) => ['street' => $a->street, 'city' => $a->city],
    );
}

A custom cast class, reusable across models

php artisan make:cast Money

class Money implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): MoneyValue
    {
        return new MoneyValue($value, $attributes['currency']);
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        return ['amount' => $value->amount, 'currency' => $value->currency];
    }
}

protected function casts(): array { return ['amount' => Money::class]; }

Default values, and what goes into JSON

protected $attributes = ['status' => 'draft', 'options' => '[]'];   // new Product() starts here

protected $hidden = ['password', 'cost_price'];
protected $visible = ['id', 'name'];                  // whitelist instead
protected $appends = ['display_price'];

$product->makeHidden('cost_price')->toArray();
$product->makeVisible('cost_price')->toJson();
$product->append('display_price');
$product->only(['id', 'name']);   $product->except(['meta']);

protected function serializeDate(DateTimeInterface $date): string   // every date in JSON
{
    return $date->format('Y-m-d H:i');
}

Pruning, delete stale rows on a schedule

use Illuminate\Database\Eloquent\{Prunable, MassPrunable};

class LoginAttempt extends Model
{
    use MassPrunable;

    public function prunable(): Builder
    {
        return static::where('created_at', '<=', now()->subDays(30));
    }
}

// routes/console.php
Schedule::command('model:prune')->daily();
Schedule::command('model:prune', ['--model' => [LoginAttempt::class]])->daily();

Events, Observers, Keys

advanced

Hook into the lifecycle

protected static function booted(): void
{
    static::creating(fn (Product $p) => $p->slug ??= Str::slug($p->name));
    static::deleted(fn (Product $p) => Cache::forget("product.{$p->id}"));
}
// retrieved, creating, created, updating, updated, saving, saved,
// deleting, deleted, trashed, restoring, restored, replicating

An observer class keeps the model clean

php artisan make:observer ProductObserver --model=Product

#[ObservedBy([ProductObserver::class])]
class Product extends Model { }

class ProductObserver
{
    public function created(Product $product): void { ... }
    public function updated(Product $product): void { ... }
}

UUID or ULID primary keys

use Illuminate\Database\Eloquent\Concerns\{HasUuids, HasUlids};

class Order extends Model
{
    use HasUlids;      // sortable, 26 chars, generated on create
}

// migration
$table->ulid('id')->primary();
$table->foreignUlid('order_id')->constrained();

Strict mode in development catches silent mistakes

// AppServiceProvider::boot
Model::shouldBeStrict(! app()->isProduction());
// throws on lazy loading, on setting an unfillable attribute,
// and on reading an attribute that was not selected
15

Eloquent Relationships

defining, querying, eager loading

Defining Relationships

core

The six you will write constantly

class User extends Model
{
    public function profile(): HasOne         { return $this->hasOne(Profile::class); }
    public function orders(): HasMany         { return $this->hasMany(Order::class); }
    public function roles(): BelongsToMany    { return $this->belongsToMany(Role::class); }
    public function latestOrder(): HasOne     { return $this->hasOne(Order::class)->latestOfMany(); }
}

class Order extends Model
{
    public function user(): BelongsTo         { return $this->belongsTo(User::class); }
    public function items(): HasMany          { return $this->hasMany(OrderItem::class); }
}

class Country extends Model
{
    // country -> users -> orders
    public function orders(): HasManyThrough  { return $this->hasManyThrough(Order::class, User::class); }
}

Override the guessed keys and tables

$this->hasMany(Order::class, 'customer_id', 'id');
$this->belongsTo(User::class, 'author_id');
$this->belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id')
     ->withPivot('granted_at')
     ->withTimestamps()
     ->as('membership');

Polymorphic, one table serving many parents

class Comment extends Model
{
    public function commentable(): MorphTo { return $this->morphTo(); }
}
class Post extends Model
{
    public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); }
}
// migration: $table->morphs('commentable');   -> commentable_id, commentable_type

// AppServiceProvider::boot, store short aliases instead of class names
Relation::enforceMorphMap(['post' => Post::class, 'video' => Video::class]);

// many to many polymorphic
public function tags(): MorphToMany { return $this->morphToMany(Tag::class, 'taggable'); }

Using Relationships

core

Property returns the results, method returns a query

$user->orders;                             // Collection, loaded once
$user->orders()->where('total', '>', 100)->get();
$user->orders()->count();
$user->profile?->bio;
$order->user->email;

Create through the relationship, keys filled in for you

$user->orders()->create(['total' => 99]);
$user->orders()->save($order);
$user->orders()->saveMany([$a, $b]);
$order->user()->associate($user)->save();
$order->user()->dissociate();

Many to many pivot operations

$user->roles()->attach($roleId);
$user->roles()->attach([1, 2 => ['granted_at' => now()]]);
$user->roles()->detach($roleId);   $user->roles()->detach();
$user->roles()->sync([1, 2, 3]);           // exactly these
$user->roles()->syncWithoutDetaching([4]);
$user->roles()->toggle([1, 2]);
$user->roles()->updateExistingPivot($roleId, ['expires_at' => $d]);

foreach ($user->roles as $role) { $role->pivot->granted_at; }

Eager Loading

everyday

The N+1 problem, and the fix

// 1 + 100 queries
foreach (Order::take(100)->get() as $order) {
    echo $order->user->name;
}

// 2 queries
foreach (Order::with('user')->take(100)->get() as $order) {
    echo $order->user->name;
}

// catch every one during development
Model::preventLazyLoading(! app()->isProduction());

Nested, constrained, and after the fact

Order::with(['user', 'items.product'])->get();
Order::with(['items' => fn ($q) => $q->where('qty', '>', 1)])->get();
Order::with('user:id,name,email')->get();      // only these columns

$orders->load('user');                          // already have the collection
$order->loadMissing('items');
Order::without('user')->get();                  // skip a $with default

Counts and aggregates without loading the rows

User::withCount('orders')->get();                     // $user->orders_count
User::withCount(['orders as paid_orders_count' => fn ($q) => $q->where('status', 'paid')]);
User::withSum('orders', 'total')->get();              // $user->orders_sum_total
User::withMax('orders', 'created_at')->get();
User::withExists('orders')->get();                    // $user->orders_exists

Querying by Relationship

everyday

Filter parents by what their children look like

User::has('orders')->get();                          // at least one
User::has('orders', '>=', 5)->get();
User::doesntHave('orders')->get();
User::whereHas('orders', fn ($q) => $q->where('total', '>', 1000))->get();
User::whereDoesntHave('orders', fn ($q) => $q->where('status', 'refunded'))->get();
User::whereRelation('orders', 'status', 'paid')->get();   // shorthand

Polymorphic and belongs to filters

Comment::whereHasMorph('commentable', [Post::class, Video::class],
    fn ($q, $type) => $q->where('published', true))->get();

Order::whereBelongsTo($user)->get();
Order::whereBelongsTo($users)->get();   // a collection of users

Touching parents and ordering by a relation

// in Comment: bump post.updated_at when a comment changes
protected $touches = ['post'];

// order users by their latest order date
User::orderByDesc(
    Order::select('created_at')->whereColumn('user_id', 'users.id')->latest()->limit(1)
)->get();
16

Factories & Seeders

fake data for tests and dev

Factories

everyday

Define the default shape once

php artisan make:factory ProductFactory --model=Product

class ProductFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name'        => fake()->words(3, true),
            'slug'        => fn (array $attrs) => Str::slug($attrs['name']),
            'price'       => fake()->randomFloat(2, 5, 500),
            'stock'       => fake()->numberBetween(0, 100),
            'active'      => true,
            'category_id' => Category::factory(),
        ];
    }

    public function inactive(): static
    {
        return $this->state(fn () => ['active' => false]);
    }

    public function configure(): static
    {
        return $this->afterCreating(fn (Product $p) => $p->tags()->attach(Tag::factory()->create()));
    }
}

Use it

Product::factory()->create();                          // saved
Product::factory()->make();                            // not saved
Product::factory()->count(20)->create();
Product::factory()->inactive()->create(['price' => 9.99]);
Product::factory()->count(3)->sequence(['tier' => 'a'], ['tier' => 'b'])->create();
Product::factory()->trashed()->create();               // soft deleted

Relationships in factories

User::factory()->has(Order::factory()->count(3))->create();
User::factory()->hasOrders(3)->create();                        // magic method
Order::factory()->for(User::factory()->state(['vip' => true]))->create();
Order::factory()->forUser(['name' => 'Ana'])->create();
User::factory()->hasAttached(Role::factory()->count(2), ['granted_at' => now()])->create();
Post::factory()->has(Comment::factory()->count(5), 'comments')->create();

Recycle a shared parent across many children

$tenant = Tenant::factory()->create();
Order::factory()->count(50)->recycle($tenant)->create();   // every order shares it

Seeders

everyday

A seeder, and the root one that calls the rest

php artisan make:seeder ProductSeeder

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        Category::factory()->count(5)
            ->has(Product::factory()->count(20))
            ->create();
    }
}

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        User::factory()->create(['email' => 'admin@example.com', 'password' => 'password']);
        $this->call([CategorySeeder::class, ProductSeeder::class]);
    }
}

Run them

php artisan db:seed
php artisan db:seed --class=ProductSeeder
php artisan migrate:fresh --seed
php artisan migrate:fresh --seeder=DemoSeeder

Fast seeding for big reference tables

use Illuminate\Database\Console\Seeds\WithoutModelEvents;

class CountrySeeder extends Seeder
{
    use WithoutModelEvents;

    public function run(): void
    {
        DB::table('countries')->insert([
            ['code' => 'RO', 'name' => 'Romania'],
            ['code' => 'RS', 'name' => 'Serbia'],
        ]);
    }
}
17

Auth & Authorization

login, gates, policies

Authentication

core

Let a starter kit generate the whole flow

laravel new shop            # pick a starter kit in the prompt
# or into an existing app
composer require laravel/breeze --dev
php artisan breeze:install

The current user, three ways

auth()->user();   Auth::user();   $request->user();
auth()->id();
auth()->check();   auth()->guest();
Auth::guard('admin')->user();

Manual login and logout

if (Auth::attempt(['email' => $email, 'password' => $password], remember: true)) {
    $request->session()->regenerate();
    return redirect()->intended('/dashboard');
}
return back()->withErrors(['email' => 'Invalid credentials.']);

Auth::login($user);
Auth::loginUsingId(1);
Auth::once($credentials);            // this request only, no session

Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
Auth::logoutOtherDevices($password);

Passwords

Hash::make('secret');                 // bcrypt, or the 'hashed' cast does it for you
Hash::check('secret', $user->password);
Hash::needsRehash($user->password);

// email verification: model implements MustVerifyEmail, route uses 'verified'
Route::get('/dashboard', ...)->middleware(['auth', 'verified']);

Gates

everyday

A closure per ability, defined in AppServiceProvider::boot

Gate::define('view-reports', fn (User $user) => $user->is_manager);
Gate::define('edit-post', fn (User $user, Post $post) => $user->id === $post->author_id);

// a super admin passes everything
Gate::before(fn (User $user) => $user->is_admin ? true : null);

Check them

Gate::allows('edit-post', $post);   Gate::denies(...);
Gate::authorize('edit-post', $post);            // throws 403
$user->can('edit-post', $post);   $user->cannot(...);
Gate::any(['edit-post', 'delete-post'], $post);
Gate::inspect('edit-post', $post)->message();   // a Response with a reason

Policies

everyday

One class per model, methods named after the actions

php artisan make:policy PostPolicy --model=Post

class PostPolicy
{
    public function viewAny(User $user): bool { return true; }
    public function view(?User $user, Post $post): bool { return $post->published || $user?->id === $post->author_id; }
    public function create(User $user): bool { return $user->hasVerifiedEmail(); }
    public function update(User $user, Post $post): bool { return $user->id === $post->author_id; }
    public function delete(User $user, Post $post): Response
    {
        return $user->id === $post->author_id
            ? Response::allow()
            : Response::deny('Only the author can delete this post.');
    }
}

Use it everywhere

// controller
$this->authorize('update', $post);
Gate::authorize('create', Post::class);

// route
Route::put('/posts/{post}', ...)->middleware('can:update,post');
Route::resource('posts', PostController::class);  // + authorizeResource in the controller

// blade
@can('update', $post) ... @endcan
@cannot('delete', $post) ... @endcannot
@canany(['update', 'delete'], $post) ... @endcanany

// anywhere
$user->can('update', $post);

Register a policy that does not follow the convention

Gate::policy(Invoice::class, BillingPolicy::class);

// or on the model
#[UsePolicy(BillingPolicy::class)]
class Invoice extends Model { }

Socialite

everyday

Login with GitHub, Google and friends in two routes

composer require laravel/socialite

// config/services.php
'github' => [
    'client_id'     => env('GITHUB_CLIENT_ID'),
    'client_secret' => env('GITHUB_CLIENT_SECRET'),
    'redirect'      => env('APP_URL').'/auth/github/callback',
],

// routes/web.php
Route::get('/auth/github', fn () => Socialite::driver('github')->redirect());

Route::get('/auth/github/callback', function () {
    $github = Socialite::driver('github')->user();

    $user = User::updateOrCreate(
        ['github_id' => $github->getId()],
        ['name' => $github->getName(), 'email' => $github->getEmail(), 'avatar' => $github->getAvatar()]
    );

    Auth::login($user, remember: true);
    return redirect('/dashboard');
});

Scopes, stateless calls and the token

Socialite::driver('google')->scopes(['openid', 'profile', 'email'])->redirect();
Socialite::driver('github')->with(['allow_signup' => 'false'])->redirect();
$user = Socialite::driver('github')->stateless()->user();      // API flows, no session

$user->token;   $user->refreshToken;   $user->expiresIn;
$user->getId();   $user->getNickname();   $user->getRaw();
18

APIs & Sanctum

resources, tokens, SPA auth

API Resources

everyday

Shape the JSON explicitly instead of dumping the model

php artisan make:resource OrderResource

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'       => $this->id,
            'number'   => $this->number,
            'total'    => (float) $this->total,
            'status'   => $this->status->value,
            'placed'   => $this->created_at->toIso8601String(),
            'customer' => new UserResource($this->whenLoaded('user')),
            'items'    => OrderItemResource::collection($this->whenLoaded('items')),
            'refund'   => $this->when($request->user()->isAdmin(), fn () => $this->refund_note),
        ];
    }
}

Return one, many, or a paginated page

return new OrderResource($order->load('items'));
return OrderResource::collection(Order::with('user')->latest()->paginate(25));
return OrderResource::make($order)->additional(['meta' => ['version' => 2]]);

Collections with their own metadata, and no data wrapper

php artisan make:resource OrderCollection

class OrderCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return ['data' => $this->collection, 'revenue' => $this->collection->sum('total')];
    }
}

// AppServiceProvider::boot, drop the outer "data" key everywhere
JsonResource::withoutWrapping();

Sanctum

L11+everyday

Install it and protect routes

php artisan install:api      # adds routes/api.php, Sanctum and the migration

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', fn (Request $request) => $request->user());
    Route::apiResource('orders', OrderController::class);
});

Issue and use tokens for mobile apps and integrations

// login endpoint
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
    throw ValidationException::withMessages(['email' => 'Invalid credentials.']);
}
$token = $user->createToken('iphone', ['orders:read'], now()->addDays(30))->plainTextToken;
return ['token' => $token];

// client sends: Authorization: Bearer 1|abc...
$request->user()->tokenCan('orders:read');
$request->user()->currentAccessToken()->delete();     // logout this device
$request->user()->tokens()->delete();                  // logout everywhere

SPA on the same domain, cookies instead of tokens

// bootstrap/app.php
$middleware->statefulApi();

// .env
SANCTUM_STATEFUL_DOMAINS=app.shop.test
SESSION_DOMAIN=.shop.test

// front end: hit this once, then normal login, then API calls with credentials
axios.get('/sanctum/csrf-cookie').then(() => axios.post('/login', creds));

JSON:API Resources

L13advanced

Laravel 13 ships first party resources that speak the JSON:API specification

// what a JSON:API response looks like, and what the resources produce for you
GET /api/orders/1?include=customer&fields[orders]=number,total
Content-Type: application/vnd.api+json

{
  "data": {
    "type": "orders",
    "id": "1",
    "attributes": { "number": "ORD-1001", "total": 129.5 },
    "relationships": {
      "customer": { "data": { "type": "users", "id": "7" } }
    },
    "links": { "self": "https://shop.test/api/orders/1" }
  },
  "included": [
    { "type": "users", "id": "7", "attributes": { "name": "Ana" } }
  ]
}
// see laravel.com/framework/docs/eloquent-resources#jsonapi-resources for the resource classes

API Conventions

everyday

Versioning with a prefix and a folder

Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api/v1.php'));

// bootstrap/app.php, change the default /api prefix
->withRouting(web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', apiPrefix: 'api/v1')

Errors as JSON, status codes that mean something

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (ModelNotFoundException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json(['message' => 'Not found.'], 404);
        }
    });
    $exceptions->shouldRenderJsonWhen(fn (Request $r) => $r->is('api/*') || $r->expectsJson());
})

// 422 validation errors come for free on JSON requests

Rate limiting and CORS

// bootstrap/app.php
$middleware->throttleApi('60,1');

// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => [env('FRONTEND_URL')],
'supports_credentials' => true,

Calling other APIs with the HTTP client

$response = Http::withToken($token)
    ->acceptJson()
    ->timeout(10)
    ->retry(3, 200)
    ->post('https://api.example.com/orders', ['sku' => 'A1']);

$response->successful();   $response->status();   $response->json('data.id');
$response->throw();        // exception on 4xx or 5xx

Http::pool(fn (Pool $pool) => [$pool->get($a), $pool->get($b)]);
Http::fake(['api.example.com/*' => Http::response(['id' => 1], 201)]);   // in tests
19

Cache, Session & Config

remember, locks, flash, locale

Cache

core

Remember, the one method you will use most

$stats = Cache::remember('dashboard.stats', now()->addMinutes(10), fn () => Stats::compute());
$user  = Cache::rememberForever("user.{$id}", fn () => User::findOrFail($id));
$value = Cache::flexible('report', [300, 3600], fn () => Report::build());   // stale while revalidate

Get, put, forget

Cache::get('key');   Cache::get('key', 'default');   Cache::get('key', fn () => compute());
Cache::put('key', $value, 600);           // seconds, or a DateTime
Cache::add('key', $value, 600);           // only if missing, returns bool
Cache::forever('key', $value);
Cache::has('key');   Cache::missing('key');
Cache::pull('key');                       // get and delete
Cache::forget('key');   Cache::flush();
Cache::increment('hits');   Cache::decrement('stock', 3);

Laravel 13: extend a TTL without reading and rewriting the value

Cache::touch('session-lock', 300);          // 5 more minutes, value untouched
Cache::touch('report', now()->addHour());

Tags, and a different store

Cache::tags(['products', "tenant:{$id}"])->put('list', $list, 3600);
Cache::tags('products')->flush();          // redis and memcached only

Cache::store('file')->get('key');
Cache::driver('array')->put(...);

Atomic locks stop two processes doing the same work

$lock = Cache::lock('import-orders', seconds: 120);

if ($lock->get()) {
    try { Importer::run(); } finally { $lock->release(); }
}

Cache::lock('report')->block(5, fn () => Report::build());   // wait up to 5s

Session

core

Read and write

session('cart');   session('cart', []);
session(['cart' => $items]);
$request->session()->get('cart');
$request->session()->put('cart.items', $items);
$request->session()->push('cart.items', $item);
$request->session()->pull('coupon');          // get and remove
$request->session()->forget(['a', 'b']);   ->flush();
$request->session()->has('cart');   ->exists('cart');   ->all();

Flash data survives exactly one more request

session()->flash('status', 'Saved.');
session()->reflash();   session()->keep(['status']);
session()->now('status', 'This request only');

Drivers, and regenerating on login

SESSION_DRIVER=database     # file, redis, cookie, array, dynamodb
php artisan make:session-table && php artisan migrate

$request->session()->regenerate();       // after login
$request->session()->invalidate();       // on logout

Config, Locale, Dates

everyday

Read and set configuration at runtime

config('mail.from.address');
config(['app.timezone' => 'Europe/Bucharest']);
Config::string('app.name');   Config::integer('session.lifetime');
php artisan config:show database
php artisan config:publish cors    # copy a stubbed config file into config/

Translations

// lang/en/messages.php  ->  'welcome' => 'Welcome, :name'
// lang/ro.json          ->  { "Welcome, :name": "Bun venit, :name" }

__('messages.welcome', ['name' => $user->name]);
trans_choice('messages.apples', 3);        // '{0} none|{1} one apple|[2,*] :count apples'
@lang('messages.welcome')   {{ __('Welcome') }}

App::setLocale('ro');   App::currentLocale();   App::isLocale('en');
php artisan lang:publish

Carbon, the date library every timestamp is

now();   today();   now()->addDays(3);   now()->subMonth()->startOfMonth();
$order->created_at->diffForHumans();              // 2 hours ago
$order->created_at->format('d M Y');   ->toDateString();   ->isoFormat('LL');
$order->created_at->isToday();   ->isPast();   ->gt($other);
Carbon::parse('2026-09-11 14:00', 'Europe/Bucharest')->utc();
Date::use(CarbonImmutable::class);   // AppServiceProvider, immutable everywhere

Context, metadata that follows a request into logs and jobs

Context::add('request_id', $id);
Context::add('tenant', $tenant->slug);
Log::info('order placed');            // includes request_id and tenant
Context::get('tenant');
// carried into queued jobs automatically
20

Storage & Files

disks, uploads, S3, URLs

The Storage Facade

everyday

Read, write, delete, on any disk

Storage::put('reports/q3.csv', $contents);
Storage::disk('s3')->put('avatars/1.jpg', $contents, 'public');
Storage::get('reports/q3.csv');
Storage::json('config.json');
Storage::exists('file.txt');   Storage::missing('file.txt');
Storage::delete(['a.txt', 'b.txt']);
Storage::copy('from', 'to');   Storage::move('from', 'to');
Storage::size('file');   Storage::lastModified('file');   Storage::mimeType('file');
Storage::prepend('log.txt', 'top');   Storage::append('log.txt', 'bottom');

Directories

Storage::files('invoices');   Storage::allFiles('invoices');
Storage::directories('/');   Storage::allDirectories('/');
Storage::makeDirectory('exports/2026');
Storage::deleteDirectory('tmp');

URLs, public and temporary

php artisan storage:link                     # public/storage -> storage/app/public

Storage::url('avatars/1.jpg');               // /storage/avatars/1.jpg
Storage::disk('s3')->url('docs/guide.pdf');
Storage::disk('s3')->temporaryUrl('private/contract.pdf', now()->addMinutes(10));
Storage::disk('s3')->temporaryUploadUrl('uploads/big.zip', now()->addHour());

Disks in config/filesystems.php

'local'  => ['driver' => 'local', 'root' => storage_path('app/private')],
'public' => ['driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public'],
's3'     => ['driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET')],

composer require league/flysystem-aws-s3-v3 "^3.0"

Uploads

everyday

Validate, then store with a generated or chosen name

$request->validate(['avatar' => ['required', 'image', 'max:2048']]);

$path = $request->file('avatar')->store('avatars', 'public');            // random name
$path = $request->file('avatar')->storeAs('avatars', "{$user->id}.jpg", 'public');
$path = $request->avatar->storePublicly('avatars', 's3');

$user->update(['avatar_path' => $path]);

// in Blade
<img src="{{ Storage::url($user->avatar_path) }}">

Several files from one input

<input type="file" name="photos[]" multiple>

foreach ($request->file('photos', []) as $photo) {
    $photo->store('photos', 'public');
}

Stream large files in and out

Storage::writeStream('big.zip', fopen($local, 'r'));
$stream = Storage::readStream('big.zip');
return Storage::download('big.zip');   // streams, does not load into memory

// fake the disk in tests
Storage::fake('public');
Storage::disk('public')->assertExists('avatars/1.jpg');
21

Laravel Queues & Jobs

dispatch, workers, failures

Jobs

everyday

A job class, with the knobs that matter in production

php artisan make:job ProcessOrder

class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;
    public array $backoff = [10, 60, 300];      // seconds between attempts

    public function __construct(public Order $order) {}

    public function handle(PaymentGateway $gateway): void
    {
        $gateway->capture($this->order);
    }

    public function failed(?Throwable $e): void
    {
        $this->order->update(['status' => 'failed']);
    }
}

Laravel 13: the same settings as attributes

use Illuminate\Queue\Attributes\{Tries, Backoff, Timeout, FailOnTimeout, MaxExceptions};

#[Tries(3)]
#[Backoff(10)]
#[Timeout(120)]
#[FailOnTimeout]
#[MaxExceptions(2)]
class ProcessOrder implements ShouldQueue
{
    use Queueable;
    public function __construct(public Order $order) {}
    public function handle(PaymentGateway $gateway): void { ... }
}

Dispatching

ProcessOrder::dispatch($order);
ProcessOrder::dispatch($order)->onQueue('payments')->delay(now()->addMinutes(5));
ProcessOrder::dispatch($order)->onConnection('sqs');
ProcessOrder::dispatchAfterResponse($order);     // after the HTTP response is sent
ProcessOrder::dispatchSync($order);              // right now, no queue
ProcessOrder::dispatchIf($order->isPaid(), $order);
dispatch(fn () => Cache::forget('stats'))->afterCommit();

Laravel 13: route jobs to queues centrally instead of at every dispatch

// AppServiceProvider::boot
Queue::route(ProcessOrder::class, connection: 'redis', queue: 'payments');
Queue::route(RequiresVideo::class, queue: 'video');           // by interface or trait
Queue::route([
    ProcessOrder::class => ['redis', 'payments'],
    SyncInventory::class => 'sync',
]);
Queue::forward('reports', 'reports.fifo', 'sqs');

ProcessOrder::dispatch($order);        // lands on redis:payments with no onQueue call

Unique, rate limited, and transaction safe

class SyncInventory implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 3600;
    public function uniqueId(): string { return $this->product->id; }
}

// only after the surrounding DB transaction commits
ProcessOrder::dispatch($order)->afterCommit();

// middleware on the job
public function middleware(): array
{
    return [new RateLimited('api-calls'), new WithoutOverlapping($this->order->id)];
}

Chains and Batches

advanced

Run in order, stop at the first failure

Bus::chain([
    new DownloadReport($id),
    new ParseReport($id),
    new NotifyOwner($id),
])->catch(fn (Throwable $e) => Log::error($e))->dispatch();

Run in parallel, track progress

php artisan make:queue-batches-table && php artisan migrate

$batch = Bus::batch($orders->map(fn ($o) => new ProcessOrder($o)))
    ->then(fn (Batch $b) => Log::info('all done'))
    ->catch(fn (Batch $b, Throwable $e) => Log::error('one failed'))
    ->finally(fn (Batch $b) => Cache::forget('import'))
    ->allowFailures()
    ->name('nightly import')
    ->dispatch();

Bus::findBatch($batch->id)->progress();   // 0 to 100

Workers and Failures

everyday

Run a worker, and restart it after every deploy

php artisan queue:work --queue=payments,default --tries=3 --max-time=3600
php artisan queue:work --stop-when-empty      # one pass, good for cron
php artisan queue:restart                     # graceful, after deploying

Supervisor keeps workers alive

[program:shop-worker]
command=php /var/www/shop/artisan queue:work --sleep=3 --tries=3 --max-time=3600
numprocs=4
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/www/shop/storage/logs/worker.log

Failed jobs

php artisan queue:failed
php artisan queue:retry 5      php artisan queue:retry all
php artisan queue:forget 5     php artisan queue:flush
php artisan queue:prune-failed --hours=48

Drivers and monitoring

QUEUE_CONNECTION=redis      # database, sqs, beanstalkd, sync for local

composer require laravel/horizon     # dashboard and worker config for Redis
php artisan horizon
php artisan queue:monitor redis:default --max=100
22

Events & Notifications

listeners, mail, broadcasting

Events and Listeners

L11+everyday

Auto discovered, no registration step

php artisan make:event OrderShipped
php artisan make:listener SendShippingNotification --event=OrderShipped

class OrderShipped
{
    use Dispatchable, SerializesModels;
    public function __construct(public Order $order) {}
}

class SendShippingNotification implements ShouldQueue
{
    public function handle(OrderShipped $event): void
    {
        $event->order->user->notify(new ShippingUpdate($event->order));
    }
}

OrderShipped::dispatch($order);
event(new OrderShipped($order));

Closure listeners and the list command

// AppServiceProvider::boot
Event::listen(OrderShipped::class, fn (OrderShipped $e) => Log::info("shipped {$e->order->id}"));
Event::listen(queueable(fn (OrderShipped $e) => ...)->onQueue('mail'));

php artisan event:list
php artisan event:cache

Subscribers group many listeners in one class

class OrderSubscriber
{
    public function subscribe(Dispatcher $events): array
    {
        return [
            OrderShipped::class  => 'onShipped',
            OrderRefunded::class => 'onRefunded',
        ];
    }
}
Event::subscribe(OrderSubscriber::class);

Notifications

everyday

One class, many channels

php artisan make:notification InvoicePaid

class InvoicePaid extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Invoice $invoice) {}

    public function via(object $notifiable): array
    {
        return ['mail', 'database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject("Invoice {$this->invoice->number} paid")
            ->greeting("Hi {$notifiable->name},")
            ->line('Thanks, your payment went through.')
            ->action('View invoice', route('invoices.show', $this->invoice))
            ->line('See you next month.');
    }

    public function toArray(object $notifiable): array
    {
        return ['invoice_id' => $this->invoice->id, 'amount' => $this->invoice->total];
    }
}

Send it, and read the database ones

$user->notify(new InvoicePaid($invoice));
Notification::send($users, new InvoicePaid($invoice));
Notification::route('mail', 'ops@example.com')->notify(new ServerDown);

php artisan make:notifications-table && php artisan migrate
$user->unreadNotifications;
$user->notifications()->where('type', InvoicePaid::class)->get();
$user->unreadNotifications->markAsRead();

Other channels

composer require laravel/slack-notification-channel
public function toSlack(object $n): SlackMessage { return (new SlackMessage)->text('Invoice paid'); }

composer require laravel/vonage-notification-channel     // SMS
public function toVonage(object $n): VonageMessage { return (new VonageMessage)->content('Paid.'); }

public function toBroadcast(object $n): BroadcastMessage { return new BroadcastMessage([...]); }

Mail

everyday

A mailable with a Markdown template

php artisan make:mail OrderReceipt --markdown=mail.orders.receipt

class OrderReceipt extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(public Order $order) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: "Receipt for order {$this->order->number}");
    }

    public function content(): Content
    {
        return new Content(markdown: 'mail.orders.receipt', with: ['total' => $this->order->total]);
    }

    public function attachments(): array
    {
        return [Attachment::fromStorage("invoices/{$this->order->id}.pdf")->as('invoice.pdf')];
    }
}

Mail::to($user)->cc($ops)->send(new OrderReceipt($order));
Mail::to($user)->queue(new OrderReceipt($order));

The Markdown template

<x-mail::message>
# Thanks for your order

Order **{{ $order->number }}** for {{ $total }} EUR is confirmed.

<x-mail::button :url="route('orders.show', $order)">
View order
</x-mail::button>

<x-mail::panel>Ships within 2 working days.</x-mail::panel>

Thanks,<br>{{ config('app.name') }}
</x-mail::message>

Preview in the browser, and local drivers

Route::get('/preview', fn () => new OrderReceipt(Order::first()));

MAIL_MAILER=log        # writes to storage/logs
MAIL_MAILER=smtp       # Mailpit at localhost:1025 during development

Broadcasting

L11+advanced

Reverb, the first party WebSocket server

php artisan install:broadcasting     # Reverb + Echo + routes/channels.php
php artisan reverb:start

class OrderShipped implements ShouldBroadcast
{
    public function broadcastOn(): array
    {
        return [new PrivateChannel("orders.{$this->order->id}")];
    }
}

// routes/channels.php
Broadcast::channel('orders.{orderId}', fn (User $user, int $orderId) =>
    $user->id === Order::findOrNew($orderId)->user_id);

// front end
Echo.private(`orders.${id}`).listen('OrderShipped', (e) => update(e.order));
23

Scheduling & Commands

routes/console.php, cron

The Scheduler

L11+everyday

Define tasks in routes/console.php

use Illuminate\Support\Facades\Schedule;

Schedule::command('app:send-reminders')->dailyAt('08:00');
Schedule::command(PruneOldLogs::class, ['--days=30'])->weekly();
Schedule::job(new SyncInventory)->everyFifteenMinutes();
Schedule::call(fn () => Cache::forget('stats'))->hourly();
Schedule::exec('certbot renew')->monthly();

The single cron entry on the server

* * * * * cd /var/www/shop && php artisan schedule:run >> /dev/null 2>&1

# locally, instead of cron
php artisan schedule:work
php artisan schedule:list
php artisan schedule:test

Frequencies

->everyMinute()   ->everyFiveMinutes()   ->everyThirtyMinutes()
->hourly()   ->hourlyAt(17)   ->everyOddHour()
->daily()   ->dailyAt('13:00')   ->twiceDaily(1, 13)
->weekly()   ->weeklyOn(1, '8:00')   ->weekdays()   ->weekends()
->monthly()   ->monthlyOn(4, '15:00')   ->lastDayOfMonth()
->quarterly()   ->yearly()
->cron('0 */4 * * *')
->between('8:00', '17:00')   ->unlessBetween(...)   ->timezone('Europe/Bucharest')
->when(fn () => Feature::active('reports'))   ->environments(['production'])

Production guards

Schedule::command('app:import')
    ->hourly()
    ->withoutOverlapping(expiresAt: 60)     // skip if still running
    ->onOneServer()                         // once across the whole fleet
    ->runInBackground()
    ->evenInMaintenanceMode()
    ->onSuccess(fn () => Log::info('ok'))
    ->onFailure(fn () => Slack::alert('import failed'))
    ->emailOutputOnFailure('ops@example.com')
    ->pingOnSuccess($healthcheckUrl);
24

Testing with Pest

Pest, HTTP tests, fakes

Pest Basics

core

A feature test that hits a route

php artisan make:test ProductTest --pest

// tests/Feature/ProductTest.php
use App\Models\{Product, User};
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('lists active products', function () {
    Product::factory()->count(3)->create(['active' => true]);
    Product::factory()->create(['active' => false]);

    $this->get(route('products.index'))
        ->assertOk()
        ->assertViewHas('products', fn ($p) => $p->count() === 3);
});

it('requires a name', function () {
    $this->actingAs(User::factory()->create())
        ->post(route('products.store'), ['price' => 10])
        ->assertSessionHasErrors('name');
});

Run them

php artisan test
php artisan test --filter=ProductTest
php artisan test --parallel
php artisan test --coverage --min=80
./vendor/bin/pest --dirty        # only files changed in git

Lazily refresh, faster suites with many non database tests

use Illuminate\Foundation\Testing\LazilyRefreshDatabase;

uses(LazilyRefreshDatabase::class)->in('Feature');   // tests/Pest.php

Architecture tests, rules about the code itself

// tests/Feature/ArchTest.php
arch()->preset()->php();
arch()->preset()->laravel();
arch()->preset()->security();

arch('no debug calls')->expect(['dd', 'dump', 'ray', 'var_dump'])->not->toBeUsed();
arch('models')->expect('App\Models')->toExtend(Model::class)->toOnlyBeUsedIn(['App\Http', 'App\Jobs', 'Database']);
arch('controllers')->expect('App\Http\Controllers')->toHaveSuffix('Controller')->not->toUse('Illuminate\Support\Facades\DB');
arch('strict')->expect('App')->toUseStrictTypes();

Datasets, one test many inputs

it('rejects bad emails', function (string $email) {
    $this->post('/register', ['email' => $email])->assertSessionHasErrors('email');
})->with(['not-an-email', 'missing@tld', '@example.com']);

test('the sum is correct')->expect(fn () => Cart::total([10, 20]))->toBe(30);

HTTP Assertions

everyday

Requests

$this->get('/orders');   $this->post('/orders', $data);   $this->put(...);   $this->patch(...);   $this->delete(...);
$this->getJson('/api/orders');   $this->postJson('/api/orders', $data);
$this->actingAs($user)->get('/dashboard');
$this->actingAs($user, 'sanctum')->getJson('/api/user');
$this->withHeaders(['X-Tenant' => 'acme'])->get('/');
$this->withSession(['cart' => []])->get('/checkout');
$this->withoutMiddleware()->get('/');
$this->followingRedirects()->post('/login', $creds)->assertSee('Dashboard');

The assertions that cover most tests

GroupAssertions
StatusassertOk, assertCreated, assertNoContent, assertNotFound, assertForbidden, assertUnauthorized, assertUnprocessable, assertStatus(418)
RedirectsassertRedirect('/x'), assertRedirectToRoute('orders.show', $o), assertRedirectBack
SessionassertSessionHas('status'), assertSessionHasErrors(['email']), assertSessionHasNoErrors, assertSessionMissing
ViewsassertViewIs('orders.index'), assertViewHas('orders'), assertSee('text'), assertSeeText, assertDontSee, assertSeeInOrder([...])
JSONassertJson([...]), assertExactJson, assertJsonFragment, assertJsonPath('data.0.id', 1), assertJsonCount(3, 'data'), assertJsonStructure, assertJsonValidationErrors('email')
Fluent JSONassertJson(fn (AssertableJson $j) => $j->where('id', 1)->has('items', 3)->missing('secret')->etc())
Headers and cookiesassertHeader('X-Id'), assertCookie('theme'), assertDownload('file.pdf')
DatabaseassertDatabaseHas('orders', [...]), assertDatabaseMissing, assertDatabaseCount('orders', 3), assertModelExists($o), assertSoftDeleted($o)
AuthassertAuthenticated, assertGuest, assertAuthenticatedAs($user)

Fakes

everyday

Nothing is really sent, everything is recorded

Queue::fake();
ProcessOrder::dispatch($order);
Queue::assertPushed(ProcessOrder::class, fn ($job) => $job->order->is($order));
Queue::assertPushedOn('payments', ProcessOrder::class);
Queue::assertNothingPushed();

Mail::fake();
Mail::assertSent(OrderReceipt::class, fn ($mail) => $mail->hasTo($user->email));
Mail::assertQueued(OrderReceipt::class);
Mail::assertNotSent(OrderReceipt::class);

Notification::fake();
Notification::assertSentTo($user, InvoicePaid::class);
Notification::assertNothingSent();

Event::fake([OrderShipped::class]);          // only these, listeners for the rest still run
Event::assertDispatched(OrderShipped::class);

Storage::fake('s3');
Storage::disk('s3')->assertExists('avatars/1.jpg');

Bus::fake();   Http::fake();   Process::fake();   Cache::spy();

Time travel

$this->travel(5)->days();
$this->travelTo(now()->addMonth());
$this->freezeTime();
Carbon::setTestNow('2026-01-01');

Mocking a bound service

$this->mock(PaymentGateway::class, function (MockInterface $mock) {
    $mock->shouldReceive('charge')->once()->with(99.0)->andReturn(new Charge('ch_1'));
});

$this->partialMock(Geocoder::class, fn ($m) => $m->shouldReceive('lookup')->andReturn([0, 0]));
$this->instance(Clock::class, new FrozenClock);
$this->swap(Clock::class, new FrozenClock);

Browser tests with Dusk

composer require laravel/dusk --dev && php artisan dusk:install

$this->browse(function (Browser $browser) {
    $browser->loginAs($user)
        ->visit('/checkout')
        ->type('card', '4242424242424242')
        ->press('Pay')
        ->waitForText('Thank you')
        ->assertPathIs('/orders/1');
});
25

Helpers & Laravel Collections

Str, Arr, collect, pipeline

Collections

core

Chain instead of writing loops

$revenueByCustomer = collect($orders)
    ->filter(fn ($o) => $o->status === 'paid')
    ->groupBy(fn ($o) => $o->user->name)
    ->map(fn ($group) => $group->sum('total'))
    ->sortDesc();

The methods you reach for every day

DoMethods
Transformmap, mapWithKeys, flatMap, transform, pluck('email', 'id'), keyBy('id'), values, keys, flip, collapse, flatten
Filterfilter, reject, where('status', 'paid'), whereIn, whereNotNull, whereBetween, first, firstWhere, last, unique('email'), duplicates
Aggregatecount, sum('total'), avg, min, max, median, countBy, groupBy, partition, isEmpty, isNotEmpty
OrdersortBy('name'), sortByDesc, sortKeys, reverse, shuffle, take(5), skip(5), slice, chunk(100), splitIn(3)
Combinemerge, concat, union, intersect, diff, zip, crossJoin, combine, pad
Testcontains, doesntContain, every, some, has, hasAny, search
Reducereduce, pipe, tap, each, eachSpread, implode(', '), join(', ', ' and ')
OutputtoArray, toJson, all, dd, dump, ensure(Order::class)

Eloquent collections add model aware methods

$orders->load('items');   $orders->loadMissing('user');
$orders->modelKeys();                     // [1, 2, 3]
$orders->toQuery()->update(['exported' => true]);
$orders->fresh();   $orders->except([1]);   $orders->only([2, 3]);
$orders->contains($order);   $orders->find(5);

Lazy collections for data that will not fit in memory

LazyCollection::make(function () {
    $h = fopen('huge.log', 'r');
    while (($line = fgets($h)) !== false) yield $line;
})
->filter(fn ($l) => str_contains($l, 'ERROR'))
->take(100)
->each(fn ($l) => Log::warning($l));

User::lazy()->filter(fn ($u) => $u->inactive())->each->delete();

Str and Stringable

core

Case, slug, limit, plural

Str::slug('Hello World');            Str::camel('hello_world');
Str::snake('helloWorld');            Str::title('hello world');
Str::limit('Hello world', 8);        Str::plural('apple', 3, prepend: true);
Str::studly('order_item');   Str::kebab('OrderItem');   Str::headline('order_item');

Testing and slicing

Str::contains($s, ['a', 'b']);   Str::startsWith($s, 'http');   Str::endsWith($s, '.pdf');
Str::before($s, '@');   Str::after($s, '@');   Str::between($s, '[', ']');
Str::replace('a', 'b', $s);   Str::remove('-', $s);   Str::squish("  a   b ");
Str::padLeft('5', 3, '0');   Str::mask('4242424242424242', '*', 4, 8);
Str::uuid();   Str::ulid();   Str::random(32);   Str::password();
Str::markdown('# Hi');   Str::wordCount($s);   Str::words($s, 10);

Fluent form

str('  Hello World  ')->trim()->lower()->slug()->toString();
Str::of($email)->after('@')->before('.')->title();
$request->string('name')->trim()->limit(50);

Arr, Number and Global Helpers

everyday

Arrays with dot notation

Arr::get($data, 'user.address.city', 'n/a');
Arr::set($data, 'user.age', 30);   Arr::has($data, 'user.email');   Arr::forget($data, 'tmp');
Arr::only($data, ['a', 'b']);   Arr::except($data, ['pw']);   Arr::pluck($users, 'email');
Arr::first($xs, fn ($x) => $x > 2);   Arr::where($xs, fn ($x) => $x > 2);
Arr::flatten($nested);   Arr::dot($nested);   Arr::undot($flat);
Arr::wrap($maybeArray);   Arr::random($xs);   Arr::sortRecursive($xs);   Arr::toCssClasses([...]);
data_get($obj, 'items.*.price');   data_set($obj, 'meta.seen', true);

Number formatting, locale aware

Number::format(1234.567, 2);
Number::currency(1234.567, 'USD');
Number::abbreviate(1234);
Number::percentage(42);
Number::fileSize(1610612736, precision: 1);
Number::spell(1234);

Small helpers that remove boilerplate

tap($user, fn ($u) => $u->touch());          // returns $user
$order = tap(new Order)->save();
once(fn () => expensive());                  // memoised per instance
value($maybeClosure);   optional($user)->name;   rescue(fn () => risky(), default: null);
retry(3, fn () => Http::get($url), sleepMilliseconds: 200);
throw_if($cond, Exception::class);   throw_unless(...);
blank($x);   filled($x);   class_basename(Order::class);
dd($x);   dump($x);   ray($x);   report($e);   logger('debug msg');
app(PaymentGateway::class);   resolve(...);   config(...);   view(...);   route(...);
asset('img/logo.png');   url('/path');   public_path();   storage_path();   base_path();
now();   today();   fake();   str();   collect();   old();   csrf_token();   bcrypt();

Run independent work in parallel processes

use Illuminate\Support\Facades\Concurrency;

[$orders, $revenue, $signups] = Concurrency::run([
    fn () => Order::whereDate('created_at', today())->count(),
    fn () => Order::whereDate('created_at', today())->sum('total'),
    fn () => User::whereDate('created_at', today())->count(),
]);

Concurrency::defer([fn () => Metrics::flush()]);       // after the response is sent
Concurrency::driver('fork')->run([...]);               // process (default), fork, sync

Pipeline, a chain of steps over one value

$order = Pipeline::send($order)
    ->through([ApplyCoupon::class, CalculateTax::class, ReserveStock::class])
    ->then(fn ($o) => $o->save());

// each step: public function handle($order, Closure $next) { ...; return $next($order); }
26

Debugging & Tooling

seeing what the app is doing

Dump and Inspect

core

The three you will type most

dump($order);                 // print and continue
dd($order, $user);            // print and die
ddd($order);                  // die with a full Ignition page

$order->dd();   $orders->dump();   Order::where(...)->dd();   {{ dump($x) }}
dd(request()->all(), session()->all(), config('mail'));

See every query, with bindings and timing

// AppServiceProvider::boot, local only
DB::listen(function (QueryExecuted $q) {
    Log::debug($q->sql, ['bindings' => $q->bindings, 'ms' => $q->time]);
});

DB::enableQueryLog();  ...;  dd(DB::getQueryLog());
Order::where('status', 'paid')->where('total', '>', 100)->latest()->limit(20)->dumpRawSql();

Environment and model summaries

php artisan about
php artisan about --only=environment
php artisan model:show Order        # columns, casts, relations, observers
php artisan route:list --path=orders
php artisan config:show cache
php artisan env

Time a block of code

use Illuminate\Support\Benchmark;

Benchmark::dd(fn () => Order::with('items')->get());              // 12.4ms
Benchmark::dd(['eager' => fn () => ..., 'lazy' => fn () => ...], iterations: 10);
[$result, $ms] = Benchmark::value(fn () => Report::build());

Debug Tools

everyday

Telescope, a full request debugger at /telescope

composer require laravel/telescope --dev
php artisan telescope:install && php artisan migrate

// production gate, app/Providers/TelescopeServiceProvider.php
protected function gate(): void
{
    Gate::define('viewTelescope', fn (User $user) => $user->is_admin);
}

php artisan telescope:prune --hours=48

Debugbar, queries and timings at the bottom of every page

composer require barryvdh/laravel-debugbar --dev
DEBUGBAR_ENABLED=true

Debugbar::info($order);   Debugbar::measure('report', fn () => Report::build());

Pulse for production health, Nightwatch for hosted monitoring

composer require laravel/pulse
php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider"
php artisan migrate
# /pulse: slow queries, slow requests, slow jobs, exceptions, cache hit rate, queue depth

# Nightwatch: first party hosted APM, https://nightwatch.laravel.com

Catch mistakes before they reach a page

// AppServiceProvider::boot
Model::shouldBeStrict(! app()->isProduction());   // lazy loading, unfillable, missing attributes
DB::prohibitDestructiveCommands(app()->isProduction());   // no migrate:fresh in prod
Model::preventLazyLoading(! app()->isProduction());
Http::preventStrayRequests();                     // in tests, every request must be faked

Code Quality

everyday

Pint formats, Larastan finds bugs, Rector upgrades

./vendor/bin/pint                      # ships with Laravel, PSR-12 by default
./vendor/bin/pint --test               # CI mode, fails on unformatted files
./vendor/bin/pint --dirty              # only files changed in git

composer require larastan/larastan --dev
./vendor/bin/phpstan analyse           # phpstan.neon: level 5 is a good start

composer require driftingly/rector-laravel --dev
./vendor/bin/rector process

IDE support for facades and models

composer require --dev barryvdh/laravel-ide-helper
php artisan ide-helper:generate        # facades
php artisan ide-helper:models -N       # model properties and relations as docblocks
php artisan ide-helper:meta            # container bindings for PhpStorm

Xdebug and step debugging

# Herd: toggle Xdebug from the app menu, then set a breakpoint in the IDE
# Sail:
SAIL_XDEBUG_MODE=develop,debug
sail build --no-cache && sail up -d

# a breakpoint in code without the IDE
xdebug_break();
27

Security & Deployment

errors, logging, production checklist

Security Built In

everyday

What you get without doing anything, and how to keep it

{{ $input }}                                 // XSS: escaped
DB::table('t')->where('a', $input)           // SQL injection: bound
@csrf                                        // CSRF: token checked on every POST
'password' => 'hashed'                       // bcrypt via cast
Route::get(...)->middleware('signed')        // tamper proof URLs

// the opt outs to watch for
{!! $html !!}   DB::raw("... $input")   validateCsrfTokens(except: [...])

Laravel 13: origin aware request forgery protection

// the CSRF middleware became PreventRequestForgery: it still checks the token, and now
// also verifies the Origin and Sec-Fetch-Site headers on state changing requests
$middleware->validateCsrfTokens(except: ['stripe/webhook']);   // same opt out as before

Encrypt and hash

Crypt::encryptString($secret);   Crypt::decryptString($cipher);
'api_secret' => 'encrypted'                   // a cast, transparent
Hash::make($password);   Hash::check($plain, $hash);
php artisan key:generate                      // never rotate without APP_PREVIOUS_KEYS

Headers and HTTPS

// AppServiceProvider::boot
URL::forceScheme('https');

// bootstrap/app.php
$middleware->trustProxies(at: '*', headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PROTO);
$middleware->trustHosts(at: ['shop.test', '*.shop.test']);

Errors and Logging

L11+everyday

Exception handling lives in bootstrap/app.php

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->dontReport([PaymentDeclined::class]);
    $exceptions->report(fn (StripeException $e) => Sentry::capture($e));
    $exceptions->render(fn (PaymentDeclined $e) => back()->withErrors(['card' => $e->getMessage()]));
    $exceptions->level(TimeoutException::class, LogLevel::WARNING);
    $exceptions->throttle(fn (Throwable $e) => Lottery::odds(1, 100));
})

A custom exception that renders itself

class OutOfStock extends Exception
{
    public function report(): void { Log::warning('stock', ['sku' => $this->sku]); }
    public function render(Request $request): Response
    {
        return response()->view('errors.stock', ['sku' => $this->sku], 409);
    }
}

Custom error pages, and logging

php artisan vendor:publish --tag=laravel-errors     # resources/views/errors/404.blade.php ...

Log::info('Order placed', ['id' => $order->id]);
Log::error('Charge failed', ['exception' => $e]);
Log::channel('slack')->critical('Disk full');
Log::stack(['daily', 'slack'])->warning('...');
Log::withContext(['request_id' => $id]);

LOG_CHANNEL=stack   LOG_STACK=daily,slack   LOG_LEVEL=warning

Debug mode: on locally, never in production

APP_DEBUG=false
APP_ENV=production

Deployment Checklist

everyday

The commands, in order, on every deploy

composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan optimize          # config, route, view and event caches in one
php artisan queue:restart
php artisan storage:link      # first deploy only

What to check before going live

ItemSetting or command
Debug offAPP_DEBUG=false, APP_ENV=production
Web rootnginx or Apache document root points at /public, never the project root
Permissionsstorage and bootstrap/cache writable by the web user
Cachesphp artisan optimize, and optimize:clear if something looks stale
QueueQUEUE_CONNECTION not sync, workers under Supervisor, queue:restart on deploy
Schedulerone cron line running schedule:run every minute
Sessions and cacheredis or database, not file, if there is more than one server
HTTPSAPP_URL with https, trustProxies configured behind a load balancer
Health/up endpoint answers 200, wired to uptime monitoring
Errorsan error tracker such as Sentry or Flare, LOG_LEVEL at warning or error
OPcacheenabled, opcache.validate_timestamps=0 with a restart on deploy
Backupsdatabase dumps and storage, tested restore

Platforms and speed

Forge      provisions and deploys to your own VPS, zero downtime with Envoyer
Vapor      serverless on AWS Lambda
Cloud      managed Laravel hosting from the Laravel team
Octane     keep the app booted between requests with FrankenPHP, Swoole or RoadRunner

composer require laravel/octane && php artisan octane:install
php artisan octane:start --workers=4
28

Errors & Switching

common failures, other frameworks

Errors You Will Meet

core

What the message means and what to do

MessageCause and fix
419 Page ExpiredMissing or stale CSRF token. Add @csrf to the form, check SESSION_DOMAIN, and exclude genuine webhooks with validateCsrfTokens(except:).
No application encryption key has been specifiedAPP_KEY is empty. Run php artisan key:generate, or copy the key from the environment you deployed from.
Target class [X] does not existThe container cannot resolve the class. A typo in the namespace, a missing use statement, or composer dump-autoload is needed after renaming.
Class "App\Models\X" not foundSame family. Check the file name matches the class name and the folder matches the namespace, then composer dump-autoload.
Add [name] to fillable property to allow mass assignmentMassAssignmentException. Add the column to $fillable on the model, or use $guarded = [] when the input is already validated.
SQLSTATE[42S02]: Base table or view not foundThe table does not exist. Run migrations, or check the model's $table.
SQLSTATE[HY000] [2002] Connection refusedDatabase not running or wrong DB_HOST. In Sail the host is mysql, not 127.0.0.1.
SQLSTATE[23000]: Integrity constraint violationA foreign key or unique index refused the write. Seed parents first, or use cascadeOnDelete, or validate with unique.
Attempt to read property "x" on nullA relationship or find returned null. Use ?-> , findOrFail, or eager load and check for missing rows.
Route [x] not definedroute() was called with a name that does not exist. php artisan route:list --name=x, and run route:clear if it was just added.
The stream or file could not be opened, permission deniedstorage or bootstrap/cache is not writable by the web server user. chown and chmod them.
The Mix manifest / Vite manifest not foundFront end not built. Run npm run build, or npm run dev while developing.
Unable to locate a class or view for component [x]The Blade component file or class is missing or in the wrong folder. x-forms.input maps to components/forms/input.blade.php.
Method Illuminate\Database\Eloquent\Collection::x does not existYou called a query method on loaded results. Use $user->orders() with parentheses to get a builder.
MaxAttemptsExceededExceptionA job failed more than $tries times. Read the failed_jobs table for the real exception, fix it, queue:retry.
Lazy loading violationpreventLazyLoading is on and a relationship was read in a loop. Add with() to the query.
Allowed memory size exhaustedget() on a huge table. Use chunk, lazy or cursor.
Changes not taking effectA cache is stale. php artisan optimize:clear, and queue:restart for workers.

Coming From Another Framework

everyday

The same ideas under different names

ConceptLaravelRailsDjangoExpress / Node
Routesroutes/web.phpconfig/routes.rburls.pyapp.get() in code
ControllerApp\Http\Controllersapp/controllersviews.pyroute handlers
ORMEloquentActive RecordDjango ORMPrisma, Sequelize
ModelApp\Models\Postapp/models/post.rbmodels.pyschema file
Migrationsdatabase/migrationsdb/migratemakemigrationsprisma migrate
TemplatesBladeERBDjango templatesEJS, Pug
Validationform requestsmodel validationsforms and serializerszod, joi
Middlewaremiddlewarebefore_action, Rackmiddlewaremiddleware
Background workqueues and jobsActive Job, SidekiqCeleryBullMQ
CLIphp artisanbin/railsmanage.pynpm scripts
Consoleartisan tinkerrails consolemanage.py shellnode REPL
Fake datafactoriesFactoryBotfactory_boyfaker
TestsPest, PHPUnitMinitest, RSpecunittest, pytestJest, Vitest
Auth scaffoldBreeze, JetstreamDevisedjango.contrib.authPassport, Auth.js
PackagesComposerBundlerpipnpm

The conventions to accept on day one

Model      Post            singular, StudlyCase
Table      posts           plural, snake_case, guessed from the model
Key        id              auto increment, or ulid()
Foreign    post_id         model_id
Pivot      post_tag        both singular, alphabetical
Controller PostController  resource methods index show create store edit update destroy
Route      posts.show      resource.action
View       posts/show      resources/views/posts/show.blade.php
Policy     PostPolicy      discovered by name
Factory    PostFactory     discovered by name

What Is a Laravel Cheat Sheet

A Laravel cheat sheet is a single page reference to the framework's syntax, Artisan commands and conventions, organised so that a working developer finds the right snippet in seconds instead of re-reading a chapter of the documentation. Some people write it as one word, Laravel cheatsheet; both mean the same thing.

This Laravel cheat sheet covers 28 sections and 305 snippets for Laravel 12 and 13, from installing the framework to deploying it. Every snippet that needs Laravel 11 or newer carries a version badge, and the L10 mode switch hides those for older applications. Each section is a set of cards, each card a set of copyable examples with a one line description, an optional expected output, and a short explanation of why the feature behaves the way it does.

It is written for developers who already know PHP and want the Laravel way of doing something without a detour through the docs. Read it top to bottom as a map of the framework, or use the search box and the level filter to jump straight to the line you need.

How It Differs From the Documentation

The official documentation explains; this page reminds. Every entry is the shortest working form of a feature, placed next to the mistake people usually make with it, and the whole framework fits on one scrollable page with one search box. When a snippet is not enough, the documentation is one link away and this page tells you which chapter to open.


What This Laravel Cheat Sheet Covers

The 28 sections follow the order in which a Laravel application is built, and each link below jumps to that section of the reference above.


What Laravel Is

Laravel is a PHP web framework built around a simple bet: most web applications need the same twenty things, so ship all twenty and make them pleasant to use. Routing, an ORM, authentication, validation, queues, caching, mail, scheduling, file storage and a test suite all come in the box, wired together and documented in one place.

Taylor Otwell released version 1 in 2011. Laravel 11 arrived in March 2024 with a much slimmer application skeleton, Laravel 12 followed in February 2025 with new starter kits, and Laravel 13 shipped on March 17, 2026, requiring PHP 8.3 and adding an AI SDK, JSON:API resources and attribute driven configuration. The framework follows a yearly major release cadence, with bug fixes for 18 months and security fixes for two years, so 12 is on security fixes only and 11 has reached end of life.

The design borrows openly from Rails: convention over configuration, an expressive ORM, generators for everything. If you name things the way Laravel expects, most classes are a few lines long. If you fight the conventions, you write configuration instead.

Where It Fits

  • Full stack apps with server rendered Blade views, or Livewire for interactivity without writing JavaScript.
  • Single page apps through Inertia, which lets React or Vue pages receive props straight from a controller, no API layer needed.
  • JSON APIs for mobile apps and third parties, with Sanctum for tokens and API resources for the response shape.
  • SaaS products, using Cashier for Stripe or Paddle billing, Jetstream for teams, and Horizon for the queue dashboard.
  • Internal tools and admin panels, often with Filament, which generates a full admin UI from Eloquent models.

The Laravel Request Lifecycle

A Laravel request passes through public/index.php, bootstrap/app.php, the service providers, three layers of middleware, the router and finally your controller, and the response travels back out through the same middleware in reverse. Understanding that path explains most of the framework.

Every request hits public/index.php, which loads Composer's autoloader and boots the application from bootstrap/app.php. Since Laravel 11 that one file configures the router, the middleware stack and the exception handler. Service providers run next, registering bindings in the container, and then the request enters the middleware pipeline.

Global middleware runs first, then the group for the route, web or api, then anything attached to the route itself. Each piece can inspect the request, modify it, or stop it and return a response early. That is how authentication refuses a guest before your code runs.

The router matches the URL, resolves route model bindings by querying the database, and calls your controller with its dependencies injected from the container. Whatever the controller returns is turned into a response object, passed back out through the middleware in reverse order, and sent to the browser.

public/index.php
  -> bootstrap/app.php          configure routing, middleware, exceptions
  -> service providers          register bindings, boot the app
  -> global middleware
  -> route group middleware     web or api
  -> route middleware
  -> router                     match URL, bind models
  -> controller                 dependencies injected
  -> response                   back out through the middleware

Eloquent, the Query Builder, and When to Use Which

Laravel gives you two ways to talk to the database, and they are layers rather than alternatives.

The query builder, reached through DB::table(), is a fluent way to write SQL that works the same on MySQL, Postgres, SQLite and SQL Server. It returns plain objects. It is fast, it has no opinions, and it is what you want for reports, aggregates and bulk updates.

Eloquent sits on top of it. A model class maps to a table, and every query builder method still works on it, but the results come back as model instances with relationships, attribute casting, accessors, events and soft deletes. This is where application logic belongs.

The practical rule: reach for Eloquent by default, and drop to the query builder when you would otherwise be loading thousands of models just to sum a column.

The N+1 Problem

This is the performance mistake every Laravel developer makes once. Load a list of orders, then read $order->user->name inside the loop. Each access runs its own query, so 100 orders become 101 queries.

// 101 queries
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->user->name;
}

// 2 queries
$orders = Order::with('user')->get();

Eager loading with with() fetches the related rows in one extra query. Turn on Model::preventLazyLoading() in development and Laravel throws an exception the moment it happens, which is far better than finding it in the slow query log six months later.


What Changed in Laravel 11, 12 and 13

Laravel 11 replaced the HTTP and console kernels with a single bootstrap/app.php file, Laravel 12 added new starter kits, and Laravel 13 raised the PHP floor to 8.3 and introduced an AI SDK and attribute driven configuration. If you learned Laravel before 2024, the folder structure will look emptier than you remember.

  • bootstrap/app.php now holds middleware, exception handling and routing configuration. The HTTP and Console kernels are gone.
  • routes/console.php is where scheduled tasks live. There is no Console Kernel with a schedule method.
  • Service providers collapsed to a single AppServiceProvider. Event listeners are discovered automatically.
  • routes/api.php is not there until you run php artisan install:api, which also installs Sanctum.
  • Default middleware is inside the framework and customised through methods on the Middleware object rather than files in your project.
  • SQLite is the default database for a fresh install, and the migrations for sessions, cache and jobs ship in the box.
  • Health checks at /up, per second rate limiting, and the casts() method on models were all added in 11.

Laravel 12 kept the skeleton and shipped new starter kits built on React, Vue and Livewire, with authentication and a component library included. Most Laravel 10 code runs on 12 without changes.

Laravel 13, released March 17, 2026, raises the floor to PHP 8.3 and again keeps breaking changes minimal. The visible additions:

  • Laravel AI SDK, a first party API for text generation, tool calling agents, embeddings, image and audio generation, and vector stores, provider agnostic.
  • JSON:API resources that produce specification compliant responses with relationship inclusion and sparse fieldsets.
  • Attributes everywhere: #[Middleware] and #[Authorize] on controllers, #[Tries], #[Backoff], #[Timeout] and #[FailOnTimeout] on jobs, plus more across Eloquent, events, validation and testing.
  • Queue::route, central routing of job classes to connections and queues.
  • Cache::touch, extend a TTL without rewriting the value.
  • PreventRequestForgery, the CSRF middleware now also checks request origin.
  • Vector search with whereVectorSimilarTo against PostgreSQL and pgvector.

Every snippet on this page that needs Laravel 11 or newer carries a version badge, and the L10 mode switch in the toolbar hides them if you are maintaining an older application.


Queues, and Why Everything Slow Belongs in One

A web request should do exactly enough work to respond, and nothing else. Sending email, generating a PDF, calling a payment provider, resizing an upload: each of these can take seconds, and the user is staring at a spinner for every one of them.

A queued job moves that work to a separate worker process. The request pushes a job description onto Redis or a database table and returns immediately. The worker picks it up, runs it, and retries it if it fails. The user sees a page in 50 milliseconds instead of 4 seconds, and a flaky third party API no longer takes your site down with it.

Three things to get right: run workers under Supervisor so they restart after a crash, call queue:restart on every deploy so they pick up new code, and give jobs a tries limit with a failed method so a bad job ends up in the failed jobs table instead of looping forever.


Testing Is Not Optional

Laravel makes tests cheap enough that skipping them is a choice rather than a constraint. A feature test boots the application, sends a request through the full middleware stack, and asserts on the response, the database and anything that was dispatched. It runs in milliseconds against an in memory SQLite database.

The fakes are what make it practical. Mail::fake(), Queue::fake(), Event::fake() and Storage::fake() replace the real service with a recorder, so you can assert that a receipt was queued to the right address without a mail server anywhere in sight.

it('queues a receipt when an order is placed', function () {
    Mail::fake();
    $user = User::factory()->create();

    $this->actingAs($user)->post('/orders', ['sku' => 'A1', 'qty' => 2])
        ->assertRedirect();

    Mail::assertQueued(OrderReceipt::class, fn ($m) => $m->hasTo($user->email));
});

Pest is the default test runner since Laravel 11. It is PHPUnit underneath with a lighter syntax, and every PHPUnit assertion still works. Its architecture tests are worth adopting early: a handful of rules such as no dd() in committed code and controllers never touching DB directly will catch more review comments than any linter.


Common Laravel Mistakes

These ten mistakes account for most of the Laravel bugs that reach production, and every one of them is easy to avoid once you know it exists.

  • Calling env() outside config files. It returns null once config is cached. Read from config() instead.
  • Loading relationships in a loop. The N+1 problem. Use with(), and turn on lazy loading prevention in development.
  • Setting $guarded to an empty array everywhere. That disables mass assignment protection. Validate with a form request, then pass validated().
  • Putting business logic in controllers. A controller should validate, call something, and respond. Move the something into an action class, a service, or the model.
  • Forgetting queue:restart after a deploy. Workers keep running the old code until they are restarted.
  • Running migrations without --force in production. The command silently refuses, and the deploy looks green.
  • Using file sessions or cache on more than one server. Users get logged out at random. Use Redis or the database driver.
  • APP_DEBUG true in production. Error pages leak environment variables and stack traces to anyone who triggers one.
  • Query methods on a loaded collection. $user->orders is a Collection, $user->orders() is a query. Only the second one accepts where.
  • Raw SQL built with string interpolation. Every DB::raw("... $input") is an injection risk. Use bindings.

How to Learn Laravel Well

The fastest way to learn Laravel is to build something small end to end and let the framework's conventions decide the shape of the code, with this cheat sheet open beside the editor.

  • Install it and build something small end to end: a route, a migration, a model, a form with validation, a test. The whole loop takes an afternoon.
  • Read the official documentation for each subsystem before reaching for a package. Most of what you need is already there.
  • Watch Laracasts for the mental models. The free Laravel path is enough to become productive.
  • Use php artisan tinker constantly. It answers most questions faster than a search.
  • Turn on Model::shouldBeStrict() in development. The framework will tell you about mistakes you did not know you were making.
  • Read the source. Everything is in vendor/laravel/framework, it is well written, and the editor will jump straight to it.

For the pieces around Laravel, our PHP cheat sheet covers the language itself, the SQL cheat sheet covers what Eloquent generates, the Docker cheat sheet covers what Sail runs on, the Git cheat sheet covers the workflow around every deploy, and the Tailwind cheat sheet covers the styling every starter kit ships with.


How This Laravel Cheat Sheet Is Maintained

This page is written and maintained by Bogdan Sandu for TMS Outsource, a software development agency that ships PHP and Laravel work for clients. The current revision was checked against the Laravel 13 release notes and documentation on September 11, 2026, and the version badges reflect that check. The plan is to revise it with each major Laravel release; the date in the byline at the top of this article shows the last revision. Corrections are welcome through the contact details in the footer.


Laravel Questions People Actually Ask

The questions developers search for most about Laravel and about this cheat sheet, answered in a few sentences each.

What is Laravel used for?

Laravel is a PHP web framework for building anything that speaks HTTP: full stack applications with server rendered Blade views or Inertia, JSON APIs for mobile and single page apps, SaaS products, e-commerce backends, admin panels and internal tools. It ships with routing, an ORM, authentication, queues, caching, mail, scheduling and a testing framework, so most of the plumbing is already written.

What is the difference between Eloquent and the query builder?

The query builder is a fluent, database agnostic way to write SQL, returning plain objects or arrays. Eloquent sits on top of it and maps tables to model classes, adding relationships, casting, events, scopes and soft deletes. Use Eloquent for application logic and the query builder for reports, bulk operations and anywhere you do not need model behaviour.

What is the N+1 query problem in Laravel and how do I fix it?

N+1 happens when you load a list of models and then access a relationship on each one inside a loop. Every access fires its own query, so 100 posts with authors becomes 101 queries. Fix it by eager loading with the with method, for example Post::with('author')->get(), which fetches the authors in one extra query. Model::preventLazyLoading() in development throws an exception whenever it happens.

When should I use a queue in Laravel?

Queue anything the user does not need to wait for: sending email, generating PDFs, calling third party APIs, resizing images, syncing data. The request returns immediately and a worker process picks up the job. Jobs implement ShouldQueue and are dispatched with the dispatch helper. Run workers with php artisan queue:work and supervise them so they restart after deploys and crashes.

What changed in Laravel 13?

Laravel 13 was released on March 17, 2026 and requires PHP 8.3. It keeps breaking changes to a minimum, so most Laravel 12 applications upgrade with little or no code change. It adds a first party AI SDK for text generation, agents, embeddings, images and audio, JSON:API resources, queue routing by job class with Queue::route, Cache::touch to extend a TTL, origin aware request forgery protection, vector similarity queries against pgvector, and PHP attributes for controller middleware and authorization and for job tries, backoff and timeout. Laravel 11 introduced the slim skeleton with bootstrap/app.php and routes/console.php, and Laravel 12 shipped the React, Vue and Livewire starter kits.

Is this Laravel cheat sheet up to date?

Yes. It was revised on September 11, 2026 against the Laravel 13 release notes and documentation. It covers Laravel 12 and 13, every snippet that needs Laravel 11 or newer carries a version badge, and the L10 mode switch in the toolbar hides those snippets for older applications. The date in the article byline changes with every revision.

Can I download this Laravel cheat sheet as a PDF?

Yes. The page has a print stylesheet, so your browser's print command with Save as PDF produces a clean copy: the dark theme switches to a print palette, every expected output and explanation is expanded, and the search box, navigation and footer are left out. Collapse the sections you do not need first, or run a search, and only what is still visible is printed.

How do I deploy a Laravel application?

On the server run composer install with no dev dependencies, copy a production .env with APP_DEBUG set to false, run php artisan migrate with the force flag, then cache configuration, routes, views and events with php artisan optimize. Point the web server document root at the public directory, run queue workers under Supervisor, and add the scheduler to cron with a single entry that runs php artisan schedule:run every minute. Laravel Forge and Vapor automate all of this.