Liam Hammett

Hi, I'm Liam!

liamhammett.com @LiamHammett

Address input A stylized address card entering Geocodio.
Coordinates output A stylized coordinate card leaving Geocodio. lat lng

10 Tips

20 Tips

50 Tips

for every Laravel app

Presenter Notes

I've been using PHP since I was about 9 years old and customising a phpBB forum for my gaming clan.

Then I came into Laravel and have been using it for about 12 years now. Let me tell you, 12 years is a long time to have picked up lots of little tips, tricks and preferences to make my life easier when building apps.

I'm going to share 50 of those tips with you, covering a wide range of topics from code quality to tooling, processes to styling. Whether you're a beginner or an experienced Laravel developer, there's something here for everyone. So let's dive in and see how we can make our Laravel apps even better!

“Make the change easy,
then make the easy change.”

Kent Beck

Start Better

Defaults before the first commit

laravel new my-app
Presenter Notes
You're probably familiar with running `laravel new` to make new apps, but did you know you can pass parameters to customise the process instead of going through the TUI?
laravel new my-app \
    --git \
    --livewire \
    --pest
Presenter Notes
For example you can choose whether to initialise a Git repo, which starter kit to use, if it should exclude auth, etc.
laravel new my-app \
    --using=imliam/smarter-kit
github.com/imliam/smarter-kit
Presenter Notes

You can also use a custom starter kit from a community maintained package, like the one I put together for this talk. This starter repo includes all the tips from this presentation pre-packaged to use for a fresh app, or to browse and copy-paste into an existing app as you wish.

I want you to focus on the tips and not worry too much about taking notes. You might not absorb all of them immediately, but that's okay.

You might go back to work on Monday and immediately apply one or two. You might be 6 months in the future and find yourself looking up a tip you vaguely remembered from today without even realizing it. This talk is about getting general knowledge to you, not going deep on every topic.

Starter Kit Commit History github.com/laravel/livewire-starter-kit/commits/main/
Presenter Notes

Have you ever gone back to a starter kit after making your app?

They get updated with new features and best practices all the time, so you should go back and check the recent commits once in a while to see what new apps get out-of-the-box.

npx skills add https://github.com/laravel/agent-skills/tree/main/laravel/skills/starter-kit-upgrade
github.com/laravel/agent-skills
You
Sync the latest toast notification feature from the Livewire starter kit
L
You
Sync the latest toast notification feature from the Livewire starter kit
L
starter-kit-upgrade skill is loaded and ready
You
Sync the latest toast notification feature from the Livewire starter kit
L
starter-kit-upgrade skill is loaded and ready
Looked through how the toast notification feature works in the latest starter kit
You
Sync the latest toast notification feature from the Livewire starter kit
L
starter-kit-upgrade skill is loaded and ready
Looked through how the toast notification feature works in the latest starter kit
AI
Agent
I've added toast notifications to your app!
Starter Kit Requires Blaze
Presenter Notes

If you check this, you'll see the cool stuff every new Laravel app is getting

For example, the Livewire starter kit now comes with Blaze, which optimises Blade's component rendering a ton

Blaze.dev Homepage blazephp.dev
Presenter Notes

With Blaze, you can get up to 97% faster component rendering, a huge boost if you're using something like Flux UI

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Blaze::optimize()->in(
            resource_path('views/components'),
        );
    }
}
Presenter Notes

Blaze is opt-in

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes

CarbonImmutable fixes this by returning a new instance on every operation, so your original date is never modified. One line in the service provider and now every date across your entire app - now() , model date casts, everything - comes back as a CarbonImmutable.

$userRegisteredAt = $user->created_at;
$sendOnboardingEmailAt = $userRegisteredAt->addDay();

$sendOnboardingEmailAt->diffForHumans($userRegisteredAt); // 1 second before 😱
Presenter Notes

Carbon dates are mutable by default, which can lead to some sneaky bugs. If you do $end = $start->addDay() , you might be surprised to find that $start has also changed - they're both pointing to the same object.

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes

CarbonImmutable fixes this by returning a new instance on every operation, so your original date is never modified. One line in the service provider and now every date across your entire app - now() , model date casts, everything - comes back as a CarbonImmutable.

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes
php artisan migrate 
php artisan db:seed 
php artisan migrate:fresh 
php artisan migrate:refresh 
php artisan migrate:reset 
php artisan migrate:rollback 
php artisan db:reset 
Presenter Notes

This allows positive commands like migrations and seeds, but prevents us running any commands that might delete any of our data.

We're probably using these commands in local development a lot though, so we only want to prohibit destructive commands when the app is in production.

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes
RegisterController.php
$this->validate([
    'password' => ['required', Password::defaults()],
]);
Presenter Notes

You can set the default requirements on Laravel's password rule. Now everywhere you use the 'password' validation rule, it'll consistently abide by these checks

An important one here is the `uncompromised` rule, which checks the password against haveibeenpwned and prevents users signing up with known insecure passwords

<input
    name="password"
    type="password"
    required
    autocomplete="new-password"
    passwordrules="{{ Password::defaults()->toPasswordRulesString() }}"
/>
Presenter Notes

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null,
        );
    }
}
Presenter Notes
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {

    }
}
Presenter Notes
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::unguard();
    }
}
Presenter Notes
  • Disables mass assignment protection globally, so you never need $fillable or $guarded on your models.
  • Reduces boilerplate — just add columns to your migration and use them immediately without touching the model.
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::shouldBeStrict();
    }
}
Presenter Notes
  • Enables three strict-mode checks at once: prevents lazy loading, accessing missing attributes, and silently discarding non-fillable attributes.
  • Passing !app()->isProduction() means you catch bugs loudly in dev/staging without risking exceptions in production.
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::automaticallyEagerLoadRelationships();
    }
}
Presenter Notes
  • When a relationship is accessed on one model in a collection, Laravel automatically loads it for the entire collection — no explicit with() needed.
  • A great complement to preventLazyLoading() — you get automatic N+1 prevention without having to remember to eager load everything up front.
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Relation::enforceMorphMap([
            'post' => Post::class,
            'video' => Video::class,
        ]);
    }
}
// Default Laravel
| commentable_type       | commentable_id |
|------------------------|----------------|
| "App\\Models\\Post"    | 1              |
| "App\\Models\\Video"   | 2              |
// With morph map
| commentable_type | commentable_id |
|------------------|----------------|
| "posts"          | 1              |
| "videos"         | 2              |
Presenter Notes

By default, Laravel stores the full class name (like App\Models\Post) in the morph_type column. This couples your database to your namespace structure - if you ever rename or move a model, the stored values in your database become stale.

With a morph map, you map each model to a short string like the table name instead. The database stays clean and decoupled from your code structure.

composer require spatie/laravel-morph-map-generator
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        MorphMapGenerator::resolveUsing(
            fn (Model $model): string => $model->getTable()
        );
    }
}
Presenter Notes
AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        URL::forceHttps(app()->isProduction());
    }
}
route('home'); // https://example.com

url('/about'); // https://example.com/about
Presenter Notes
App\Http\Middleware\HttpsRedirect.php
class HttpsRedirect
    {
        public function handle(Request $request, Closure $next): Response
        {
            if (! $request->isSecure() && app()->isProduction()) {
                return redirect()->secure($request->getRequestUri());
            }

            return $next($request);
        }
    }
bootstrap/app.php
return Application::configure()
        ->withMiddleware(function (Middleware $middleware): void {
            $middleware->append([
                HttpsRedirect::class,
            ]);
        })->create();
Presenter Notes

Tools To Keep Healthy

Keep the app correcting itself

composer require laravel/pint --dev
Presenter Notes

Laravel Pint is an opinionated PHP code style fixer built on top of PHP-CS-Fixer. It ships with the Laravel preset out of the box, so there's very little config needed.

composer require rector/rector --dev
Presenter Notes

Rector is a PHP refactoring tool that can automatically upgrade your code to use modern PHP features and best practices.

rector.php
RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
        __DIR__ . '/bootstrap',
        __DIR__ . '/config',
        __DIR__ . '/database',
        __DIR__ . '/resources/views',
        __DIR__ . '/routes',
    ])
    ->withSkipPath(__DIR__ . '/bootstrap/cache')
    ->withPhpSets()
    ->withPreparedSets(
        deadCode: true,
        codeQuality: true,
        typeDeclarations: true,
        privatization: true,
        earlyReturn: true,
        strictBooleans: true,
    );
Presenter Notes
composer require driftingly/rector-laravel --dev
Presenter Notes

There's also a Laravel-specific extension for Rector that adds a bunch of Laravel-specific rules .

rector.php
RectorConfig::configure()
    ->withSets([
        LaravelSetList::LARAVEL_ARRAYACCESS_TO_METHOD_CALL,
        LaravelSetList::LARAVEL_ARRAY_STR_FUNCTION_TO_STATIC_CALL,
        LaravelSetList::LARAVEL_CODE_QUALITY,
        LaravelSetList::LARAVEL_COLLECTION,
        LaravelSetList::LARAVEL_CONTAINER_STRING_TO_FULLY_QUALIFIED_NAME,
        LaravelSetList::LARAVEL_FACADE_ALIASES_TO_FULL_NAMES,
        LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER,
        LaravelSetList::LARAVEL_FACTORIES,
        LaravelSetList::LARAVEL_IF_HELPERS,
        LaravelSetList::LARAVEL_LEGACY_FACTORIES_TO_CLASSES,
        LaravelSetList::LARAVEL_TESTING,
        LaravelSetList::LARAVEL_TYPE_DECLARATIONS,
    ]);
Presenter Notes
composer.json
{
    "scripts": {
        "lint": [
            "rector",
            "pint --parallel"
        ]
    }
}
Presenter Notes
.github/workflows/lint.yml
name: linter

on:
  push:
    branches:
      - develop
      - main
      - master
      - workos
  pull_request:
    branches:
      - develop
      - main
      - master
      - workos

permissions:
  contents: write

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
        with:
          persist-credentials: false

      - name: Setup PHP
        uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
        with:
          php-version: '8.5'

      - name: Add Flux Credentials Loaded From ENV
        run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"

      - name: Install Dependencies
        run: |
          composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
          npm install

      - name: Run Pint
        run: composer lint

      # - name: Commit Changes
      #   uses: stefanzweifel/git-auto-commit-action@v7
      #   with:
      #     commit_message: fix code style
      #     commit_options: '--no-verify'
      #     file_pattern: |
      #       **/*
      #       !.github/workflows/*
Presenter Notes

The starter kits include a GitHub Actions workflow for linting your code.

But it also includes an optional, commented-out step, which I love uncommenting

This step lets the linters automatically commit changes back to your branch. No need to see a failed pipeline because of a missing trailing comma, no need to have slow precommit hooks on your machine, just let CI deal with it and you'll never think about code style again.

.github/dependabot.yml
version: 2

updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"

  - package-ecosystem: "composer"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 5
      semver-major-days: 30
      semver-minor-days: 7
      semver-patch-days: 3
    groups:
      minor-updates:
        update-types:
          - "minor"
          - "patch"
      security-updates:
        applies-to: security-updates
        update-types:
          - "minor"
          - "patch"
Presenter Notes

Dependabot is a free GitHub service that automatically opens pull requests to keep your dependencies up to date. All you need is a small YAML file in your repository.

Configure each ecosystem you use - composer for PHP packages, npm for JavaScript, and github-actions to keep your workflow versions current too.

Without it, it's easy to fall months or years behind and miss critical security patches.

By default, Dependabot opens one pull request per package. On a Laravel app with dozens of Composer dependencies, that's a lot of PRs to review.

Use groups to batch minor and patch updates together into a single pull request, keeping things manageable while still surfacing security updates clearly.

composer require phpstan/phpstan --dev
Presenter Notes

PHPStan is a static analysis tool that will analyse your code to understand how it works and find issues without needing to execute it.

composer require larastan/larastan --dev
Presenter Notes

But there's a wrapper around this called Larastan which comes with a lot of Laravel-specific rules in addition, and it better understands how Laravel works so will find more issues specific to Laravel applications, and not report as many false positives due to Laravel's use of magic methods.

phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
    - vendor/nesbot/carbon/extension.neon

parameters:

    paths:
        - app/
        - bootstrap/
        - config/
        - database/
        - routes/

    # Level 10 is the highest level
    level: 10

    treatPhpDocTypesAsCertain: false

    checkModelProperties: true

    checkConfigTypes: true

    checkOctaneCompatibility: true
Presenter Notes
phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
    - vendor/nesbot/carbon/extension.neon

parameters:

    paths:
        - app/
        - bootstrap/
        - config/
        - database/
        - routes/

    # Level 10 is the highest level
    level: 10

    treatPhpDocTypesAsCertain: false

    checkModelProperties: true

    checkConfigTypes: true

    checkOctaneCompatibility: true
Presenter Notes
composer require tomasvotruba/bladestan --dev
Presenter Notes

One thing missed by this is support for Laravel Blade, since they're not typical PHP files. Luckily there's another extension called Bladestan that adds support for that.

phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
    - vendor/nesbot/carbon/extension.neon
    - vendor/tomasvotruba/bladestan/config/extension.neon

parameters:

    paths:
        - app/
        - bootstrap/
        - config/
        - database/
        - resources/views/
        - routes/

    # Level 10 is the highest level
    level: 10

    treatPhpDocTypesAsCertain: false

    checkModelProperties: true

    checkConfigTypes: true

    checkOctaneCompatibility: true
Presenter Notes
composer require pestphp/pest --dev
Presenter Notes

Pest is one of the options for running tests in PHP. It uses PHPUnit under the hood.

It does offer lots of other things as first-party features; running in parallel, browser testing, stress testing, type coverage, mutation testing, snapshot testing, etc.

Pest is built on PHPUnit
Presenter Notes
Pest has a lot of other tools as first party features
Presenter Notes
tests/ArchitectureTest.php
arch()->preset()->php();
arch()->preset()->security();
arch()->preset()->laravel();
Presenter Notes

Pest has a built-in concept of architecture tests - tests that don't test your business logic, but instead enforce rules about how your codebase is structured. These presets cover common rules for PHP, security, and Laravel specifically, like ensuring you're not using `eval()`, `dd()`, or `sleep()` anywhere in your app code.

composer require barryvdh/laravel-debugbar --dev
Presenter Notes

Laravel Debugbar is a package that integrates PHP Debug Bar into your Laravel app. It shows you queries, requests, exceptions, views, route info, and much more - all in a collapsible toolbar at the bottom of the page. It's invaluable during local development.

composer require barryvdh/laravel-ide-helper --dev
Presenter Notes

Laravel IDE Helper generates helper files that give your IDE full autocompletion and type awareness for Laravel's facades, models, and magic methods - things that would otherwise appear as unknown to static analysis tools.

composer.json
{
    "scripts": {
        "post-update-cmd": [
           "@php artisan ide-helper:generate",
           "@php artisan ide-helper:meta",
           "@php artisan ide-helper:models --nowrite",
           "@php artisan ide-helper:eloquent"
        ]
    }
}
Presenter Notes

Add these to the post-update-cmd scripts and IDE helper files will be regenerated every time you run composer update, so they always stay in sync with your installed packages and models without any manual effort.

npm install \
  eslint \
  @eslint/js \
  prettier \
  eslint-config-prettier \
  prettier-plugin-tailwindcss \
  prettier-plugin-organize-input
package.json
{
    "scripts": {
        "build": "vite build",
        "dev": "vite",
        "format": "prettier --write resources/",
        "format:check": "prettier --check resources/",
        "lint": "eslint . --fix"
    }
}
Presenter Notes

Not every starter kit is made equal, though. Even if you're using Livewire, you might be writing a lot of custom CSS and JS, but the starter kit doesn't include tools like ESLint and Prettier out of the box, which the React/Vue starter kits do.

So it's a good idea to install these tools, copy over the config files and make sure you have them set up too.

npm install prettier-plugin-blade
.prettierrc
{
    "plugins": [
        "prettier-plugin-organize-imports",
        "prettier-plugin-blade",
        "prettier-plugin-tailwindcss"
    ],
    "overrides: [
       {
           "files": [
               "*.blade.php"
           ],
           "options": {
               "parser": "blade"
           }
       }
    ]
}
Presenter Notes

But if you're using Livewire, you're also writing your templates in Blade, so make sure to install the Prettier plugin for Blade and set it up too, to format your views.

npm install vitest
package.json
{
    "scripts": {
        "build": "vite build",
        "dev": "vite",
        "test": "vitest run",
        "test:watch": "vitest"
    }
}
example.test.js
import { test, expect } from 'vitest'

test('that true is true', () => {
  expect(true).toBe(true)
})
Presenter Notes

Since Laravel already uses Vite for asset bundling, you may as well use Vite's test runner Vitest for your JS too. There's very little extra config to add, but t gives you the same kind of Jest/Pest test style you might expect from the rest of the ecosystem.

AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Vite::useAggressivePrefetching();
    }
}
Presenter Notes

You can configure Laravel to aggressively prefetch Vite assets - it's a small progressive enhancement that tells the browser to request the extra JS/CSS asset files upfront.

Pest.php
pest()->extend(TestCase::class)
    ->beforeEach(function () {
        $this->withoutVite();
    });
Presenter Notes

In tests, Vite will throw exceptions if the manifest isn't built. Call withoutVite() in your beforeEach to disable it across the board - no more "Unable to locate file in Vite manifest" errors in CI, nor do you need to wait for npm builds before your PHP tests can run

Node Modules is the heaviest object in the universe
Presenter Notes
npm npm
Yarn Yarn
pnpm pnpm
Bun Bun
Presenter Notes

There are a lot of package managers in the JavaScript ecosystem. npm is the original and comes bundled with Node.js. Yarn came along as a faster, more reliable alternative. pnpm brought disk-space efficiency with its content-addressable store. And then there's Bun.

npm npm
Yarn Yarn
pnpm pnpm
Bun Bun
bun install   # Drop-in replacement for npm install
bun run dev   # Way faster script execution
Presenter Notes

Use Bun. It's a drop-in replacement for npm - just swap npm for bun. It's dramatically faster at installing packages and running scripts, has a built-in test runner, bundler, and TypeScript support out of the box. There's very little reason not to switch.

For a Laravel app, swap out npm for bun everywhere in your composer scripts, CI pipelines and your README. Your team will thank you for the faster installs alone.

nvm (Node Version Manager)

nvm use  # reads .nvmrc automatically
nvm install

fnm (Fast Node Manager)

fnm use  # also reads .nvmrc
fnm install
.nvmrc
25.8.0
Presenter Notes

Both nvm and fnm read the .nvmrc file automatically. nvm is the battle-tested original, but fnm is worth considering because it's written in Rust and has near-instant shell startup time.

Either way, adding a .nvmrc is the important part - it means a new developer can clone the repo and just run `nvm use` or `fnm use` and be on the exact right version immediately.

composer require laravel/boost --dev

php artisan boost:install
Presenter Notes
composer.json
{
    "scripts": {
        "post-update-cmd": [
            "@php artisan vendor:publish --tag=laravel-assets --ansi --force",
            "@php artisan boost:update"
        ]
    }
}
Presenter Notes

Add this one line to the post-update-cmd section of your composer.json.

Every time you run composer update - whether you're pulling in a new package or upgrading an existing one - Boost will automatically regenerate all of its guidelines to stay in sync with your exact dependency versions. You never have to think about it.

<input
    type="email"
    name="email"
    value="{{ app()->isLocal() ? 'admin@example.com' : '' }}"
/>

<input
    type="password"
    name="password"
    value="{{ app()->isLocal() ? 'password' : '' }}"
/>
Presenter Notes

If your app has authentication, you're logging in constantly during local development - probably dozens of times a day.

If you seed a default user in your dev environment, just prefill the login form values when running locally. It's a tiny change, but it means you can just hit submit instead of typing credentials every single time.

The values are only prefilled when app()->isLocal() is true, so they'll never appear in production.

<button>{{ __('Click me') }}</button>
Presenter Notes

Laravel's default starter kits come with translations like this - passing full strings through to the translation function

That's simple to implement, but not good practice for maintainability. You should instead do one of two things...

<button>{{ __('landing.click_me') }}</button>

<button>Click me</button>
Presenter Notes

Either use properly namespaced keys for your translations if you're going to localise your app, or just remove the translations to make it easier for you to write code if it'll only be one language.

request()->get('name');
request()->input('name');
request()->name;
request('name');
Presenter Notes

These are all ways of doing basically the same thing: retrieving a value from the request.

request()->get('name');
request()->get('priority');
request()->get('published_at');
request()->get('active');
Presenter Notes

But it's a bit more difficult when you're pulling multiple different values. Now it's not clear if these are strings, booleans, arrays or something else...

request()->string('name');
request()->integer('priority');
request()->date('published_at');
request()->boolean('active');
Presenter Notes

Luckily, Laravel provides helper methods that return a properly typed value, which makes your IDE and tools like PHPStan happy, and makes sure you're not getting bad data back that's getting malformed as it gets cast from one type to another

The same works for several things; not just requests, but also cache, query strings, Blade component attributes, validated rules, etc.

composer require spatie/laravel-error-solutions --dev
Presenter Notes

Spatie's laravel-error-solutions package enhances Laravel's error pages with extra actionable information to help you diagnose and fix problems faster.

Spatie error solutions showing suggested solutions and documentation links
Presenter Notes

Exceptions can now surface more context about what might have gone wrong - including links to relevant documentation and suggested solutions to guide you in the right direction.

Spatie error solutions showing a self-fixing exception
Presenter Notes

Even better, some exceptions can actually fix themselves. With a single click right on the error page, the fix is applied for you automatically - no manual digging required.

config/app.php
return [
    // ...

    'editor' => env('APP_EDITOR', 'phpstorm'),
];
Presenter Notes

Here's a hidden Laravel feature that not many people know about, it's not there by default but if you add an editor key to your config/app.php , Laravel's exception page will turn file paths from things like the stack trace into clickable links that open directly in your IDE.

It works with PHPStorm, VSCode, Cursor, etc.

app/Http/Middleware/AddContext.php
Context::add('url', $request->url());
Context::add('trace_id', Str::uuid()->toString());
Presenter Notes

Laravel's Context facade lets you attach arbitrary key-value data to the current request lifecycle and even queued jobs triggered in the current request.

Putting something like this in a middleware, you can stash the request URL and a unique trace ID once, and that data will automatically be included in every log entry made during that request - without having to pass it around everywhere manually.

php artisan stub:publish

# [INFO]  Stubs published successfully.

ls ./stubs

# stubs
# ├── cast.inbound.stub
# ├── cast.stub
# ├── class.invokable.stub
# ├── class.stub
# ├── console.stub
# ├── controller.api.stub
# ├── controller.invokable.stub
# ├── controller.model.api.stub
# ├── controller.model.stub
# ├── controller.nested.api.stub
# ├── controller.nested.singleton.api.stub
# ├── controller.nested.singleton.stub
# ├── controller.nested.stub
# ├── controller.plain.stub
# ├── controller.singleton.api.stub
# ├── controller.singleton.stub
# ├── controller.stub
# ├── enum.backed.stub
# ├── enum.stub
# ├── event.stub
# ├── factory.stub
# ├── job.queued.stub
# ├── job.stub
# ├── listener.queued.stub
# ├── listener.stub
# ├── listener.typed.queued.stub
# ├── listener.typed.stub
# ├── mail.stub
# ├── markdown-mail.stub
# ├── markdown-notification.stub
# ├── middleware.stub
# ├── migration.create.stub
# ├── migration.stub
# ├── migration.update.stub
# ├── model.pivot.stub
# ├── model.stub
# ├── notification.stub
# ├── observer.plain.stub
# ├── observer.stub
# ├── pest.stub
# ├── pest.unit.stub
# ├── policy.plain.stub
# ├── policy.stub
# ├── provider.stub
# ├── request.stub
# ├── resource-collection.stub
# ├── resource.stub
# ├── rule.stub
# ├── scope.stub
# ├── seeder.stub
# ├── test.stub
# ├── test.unit.stub
# ├── trait.stub
# └── view-component.stub
Presenter Notes
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('{{ table }}');
    }
};
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('{{ table }}');
    }
};
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }
};
return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }
};
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }
};
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
        });
    }
};
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->uuid()->primary();
            $table->timestamps();
        });
    }
};
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('{{ table }}', function (Blueprint $table) {
            $table->uuid()->primary();
            $table->softDeletes();
            $table->timestamps();
        });
    }
};

Testing

Make slow and random things predictable

sleep(2);
Sleep::for(2)->seconds();

Sleep::for(300)->milliseconds();

Sleep::until(now()->addSeconds(30));
pest()->extend(TestCase::class)
    ->beforeEach(function () {
        Sleep::fake();
    });
Presenter Notes
test('we dont wait for 2 seconds', function () {
    invoke_code_that_calls_sleep();

    Sleep::assertSleptTimes(0);
    Sleep::assertNeverSlept();
});
Presenter Notes
pest()->extend(TestCase::class)
    ->beforeEach(function () {
        Http::fake([
            'api.example.com/*' => Http::response(['data' => 'value']),
        ]);

        Http::preventStrayRequests();
    });
Presenter Notes

When testing code that makes HTTP requests, Http::fake() lets you intercept them and return pre-defined responses. But what happens to any requests you forgot to fake?

Http::preventStrayRequests() will throw an exception if any HTTP request is made that hasn't been faked. This prevents your tests from accidentally hitting real external services, making them faster, more reliable, and truly isolated.

test('the article has an id', function () {
    Str::createUuidsUsing(fn () => 'abcdefgh-ijkl-mnop-qrst-uvwxyz123456');

    $article = Article::create();

    expect($article->uuid)->toBe('abcdefgh-ijkl-mnop-qrst-uvwxyz123456');

    Str::createUuidsNormally();
});
Presenter Notes
pest()->extend(TestCase::class)
    ->beforeEach(function () {
        Str::createRandomStringsNormally();
        Str::createUlidsNormally();
        Str::createUuidsNormally();
    });
Presenter Notes
“Good documentation is like a love letter to your future self.”

Anita Pari

Top of readme file on GitHub
Presenter Notes

A lot of project readme files get cluttered with too many sections under different headings, but you can actually omit some

If you have a separate `CONTRIBUTING.md`, `LICENSE.md` or `SECURITY.md` file, GitHub makes those available as different tabs at the top of the readme automatically

So you can focus on what really matters in your project's documentation

Contributor Covenant contributor-covenant.org
Presenter Notes

If you need contribution guidelines, particularly for open source projects, the contributor covenant can be a good starting point.

Choose a license choosealicense.com
Presenter Notes

And if you're not sure what license to go for because you don't speak legalese, check out ChooseALicense.com, which will help make it super clear in laymans terms what each major license means.

Top of readme file on GitHub
Presenter Notes

I think project documentation is often overlooked, but a readme is the first thing someone will read about your project, regardless of whether it's open source, or an internal commercial project and someone is getting started with it.

Installation section of readme file on GitHub
Presenter Notes

The most important thing is to make sure your installation instructions are clear and easy to follow. Most project docs stop at running `composer install` then `artisan serve`, but there's a lot more steps to run to get all of Laravel's built-in features working properly

So remember to note those things like setting up storage and writeable files

Ideally you'd have all of this in a single easy-to-run script if it starts to get too long

Running the app section of readme file on GitHub
Presenter Notes

You also need to make sure people can run the app and perform common tasks like running tests or fixing code style. You might also cover this in CI, but it's important to be able to do manually too.

Deployments section of readme file on GitHub
Presenter Notes

You should also note how deployments are handled for your app. If it's an app people will deploy in multiple places (like an open source project, or a per-client install) there will be steps you need to do for each new install, like defining environment variables or setting up the scheduler.

Deployments section of readme file on GitHub
Presenter Notes

You also need to remember that there are steps you should take in a certain order when doing deployments, as there's a lot you could miss like clearing caches or restarting queue workers.

This will probably change a lot if you've got zero downtime deployments or continuous delivery or use Forge or Cloud or any number of other deployment methods. Just make sure it's documented.

FilamentServiceProvider.php
class FilamentServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Select::configureUsing(fn (Select $field): Select => $field
            ->searchable()
            ->preload());

        // Add sensible min and max values to prevent dates like 01/01/0000 or 01/01/3000
        DatePicker::configureUsing(fn (DatePicker $datePicker): DatePicker => $datePicker
            ->minDate(Date::createFromDate(1500, 1, 1))
            ->maxDate(now()->addYears(30)));

        Table::configureUsing(fn (Table $table): Table => $table
            ->striped()
            ->deferLoading()
            ->reorderableColumns()
            ->columnManagerColumns(2)
            ->columnManagerTriggerAction(fn (Action $action): Action => $action->button()->label('Columns'))
            ->filtersTriggerAction(fn (Action $action): Action => $action->button()->label('Filters')->slideOver()->closeModalByClickingAway(true))
            ->filtersFormWidth(Width::Small)
            ->paginationPageOptions([10, 25, 50, 100]));

        Column::configureUsing(fn (Column $column): Column => $column->toggleable());

        TextColumn::configureUsing(fn (TextColumn $textColumn): TextColumn => $textColumn
            ->searchable()
            ->sortable());

        Notification::configureUsing(fn (Notification $notification): Notification => $notification->duration(10_000));
    }
}
Presenter Notes

A Modern CSS Reset

Small defaults, better interfaces

Presenter Notes

If you're building a Laravel app, chances are you're building a frontend for it in the web. And if you're building a frontend, chances are you're writing CSS.

CSS has come a long way in the last few years - we have new features like popovers, modals, container queries and so much more...

But all of those things are situational, you need to know when to use them

What I'm going to show you here are a number of CSS features you can apply to any project to just give a little progressive enhancement to any app you build.

@view-transition {
    navigation: auto;
}

:root {
    accent-color: #ff6600;
}

html {
    scroll-behavior: smooth;
    scroll-padding-top: 1rem;
}

h1, h2, h3, h4, h5, h6 {
    text-wrap: balance;
}

p {
    text-wrap: pretty;
    max-width: 75ch;
    hanging-punctuation: first last;
}

img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}
app.css
@view-transition {
    navigation: auto;
}

/** Add a view transition name to common elements */
.header {
    view-transition-name: header;
}
Presenter Notes
<a href="..." style="view-transition-name: article-latest-updates">
    Latest updates!
</a>

<a href="..." style="view-transition-name: article-my-first-post">
    My first post
</a>
<h1 style="view-transition-name: article-my-first-post">My first post</h1>
Presenter Notes
app.css
h1, h2, h3, h4, h5, h6 {
    text-wrap: balance;
}

p {
    text-wrap: pretty;
}

This Is a Long Heading That Will Wrap More Evenly With text-wrap: balance Applied to It

The text-wrap: pretty property prevents typographic orphans - those single words that end up alone on the last line of a paragraph. The browser adjusts line breaks across the whole paragraph so the last line has at least two words, giving a more polished reading experience without any manual intervention.

Meanwhile, text-wrap: balance distributes text more evenly across lines, ideal for headings and short blocks. Both properties are progressive enhancements - browsers that don't support them simply fall back to normal wrapping.

Presenter Notes

text-wrap: balance makes text distribute more evenly across lines - great for headings so you don't get one word on its own line.

text-wrap: pretty optimises line breaks across the whole paragraph to avoid orphans - single words on the last line.

Both are progressive enhancements - no harm in adding them today.

app.css
p {
    max-width: 75ch;
}

The sweet spot for readable line length is 65–75 characters. Below that, your eye makes too many line breaks; above it, tracking back to the start of the next line becomes uncomfortable. The ch unit is perfect here — it's based on the width of the 0 character in the current font, so the constraint automatically adapts to whatever typeface and size you're using.

Presenter Notes

Typography research consistently puts the optimal line length at 65–75 characters. Too short and the eye has to jump lines too often; too long and it's hard to find the start of the next line.

The ch unit is based on the width of the "0" glyph in the current font, so max-width: 75ch automatically scales with whatever font size you've set. One line, any font, always roughly right.

You can also use it on article or .prose wrappers if you don't want to constrain every paragraph.

app.css
p {
    hanging-punctuation: first last;
}
border

There are many great quotes that can apply to software development, but this is one of my favourites:

If you want to change your culture, change your reviews. Change the systems that reward and recognize people. The culture will follow.”

– Mike Crittenden

Presenter Notes

hanging-punctuation: first last makes opening and closing punctuation marks - like quotation marks, brackets, and bullets - hang outside the text box.

This creates a much cleaner visual alignment along the left edge, a technique long used in print typography.

Currently only supported in Safari, but it's a progressive enhancement - browsers that don't support it simply ignore it. We can also fake the effect with a small negative text-indent.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}
Presenter Notes

Here's a great CSS reset for images. Let's walk through each property and see what it does visually.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}
Example
Presenter Notes

max-width: 100% prevents images breaking outside the container. Together with height: auto , the aspect ratio is maintained while the image scales down to fit.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}

Some text above

Example

Some text below

Presenter Notes

vertical-align: middle removes the phantom vertical spacing that appears below inline images. By default, images sit on the text baseline, leaving a gap below - this fixes that.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}

Some text above the image

A photo of a sunset over the ocean

Some text below the image

Presenter Notes

font-style: italic italicises the alt text that appears when an image fails to load. This makes it visually distinct from regular body text, clearly signaling it's a placeholder description rather than page content.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}
Example
<img src="hero.jpg" style="background-image: url(hero-lowres.jpg)" />
Presenter Notes

background-repeat: no-repeat and background-size: cover let you use a tiny low-resolution version as the background image. On slow connections, users see the blurry placeholder first while the full image loads in - a technique similar to progressive JPEGs.

app.css
img {
    max-width: 100%;
    height: auto;
    vertical-align: middle;
    font-style: italic;
    background-repeat: no-repeat;
    background-size: cover;
    shape-margin: 1rem;
}
Example

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

Presenter Notes

shape-margin: 1rem ensures any text wrapping around an image with a clip-path or shape-outside doesn't press directly against it. It adds a comfortable gap between the text flow and the shape boundary.

app.css
:root {
    accent-color: #ff6600;
}
accent-color
Presenter Notes

The accent-color property sets the colour used by native form controls - checkboxes, radio buttons, range sliders, and progress bars - in one single line.

Previously you'd need to hide the native control and build a custom replacement. Now you can just set accent-color on :root and all of these controls adopt your brand colour automatically.

It's one line of CSS with zero JavaScript and zero custom components required.

app.css
html {
    scroll-behavior: smooth;
    scroll-padding-top: 1rem;
}

#section-a

Here is section A. It has some content to fill space and make scrolling meaningful. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor.

#section-b

Here is section B. Without scroll-padding-top, clicking the anchor would place this heading right behind the sticky nav. With it, the browser offsets the scroll target so this heading stays visible below the nav bar.

End of page content.

Presenter Notes

scroll-behavior: smooth animates in-page anchor jumps instead of an instant teleport - no JavaScript needed.

scroll-padding-top offsets where the browser considers the top of the scroll area to be. Without it, clicking an anchor on a page with a sticky header will hide the target element behind the nav bar. Set it to the height of your fixed header and the target lands just below it instead.

Both are one-liners that respect prefers-reduced-motion in modern browsers - scroll-behavior: smooth is automatically suppressed for users who prefer reduced motion.

resources/views/layouts/app.blade.php
@if(app()->isLocal())
    @vite('resources/css/dev.css')
@endif
Presenter Notes

You can create a dev.css file that only gets loaded in your local environment. This lets you add styles that are purely for development purposes - things that help you spot issues or find elements on the page - without ever shipping them to production.

resources/css/dev.css
img:not([alt]) {
    outline: 3px dotted red;
}
Presenter Notes

A simple but effective one: give a red outline to any image that's missing an alt attribute. Accessibility issues like this are easy to miss during development, but this makes them impossible to ignore.

Recap

“The enemy of art is the absence of limitations.”

Orson Welles

That's All, Folks!

liamhammett.com @LiamHammett

github.com/imliam/smarter-kit

/

Keyboard Shortcuts

Navigate and control your presentation

Navigation

Previous slide / sub-slide
PgUp ,
Next slide / sub-slide
Space PgDn .
Previous sub-slide
Next sub-slide
First slide
Home ⌘←
Last slide
End ⌘→
Previous slide (skip sub-slides) ⌘↑
Next slide (skip sub-slides) ⌘↓

Actions

Toggle fullscreen F
Open presenter view P
Toggle big mode B
Toggle compact mode C
Show this help
? /
Close this help Esc
Big Mode B
Compact Mode C