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
laravel new my-app
laravel new my-app \
--git \
--livewire \
--pest
laravel new my-app \
--using=imliam/smarter-kit
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.
github.com/laravel/livewire-starter-kit/commits/main/
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
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
blazephp.dev
With Blaze, you can get up to 97% faster component rendering, a huge boost if you're using something like Flux UI
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Blaze::optimize()->in(
resource_path('views/components'),
);
}
}
Blaze is opt-in
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,
);
}
}
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,
);
}
}
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 😱
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.
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,
);
}
}
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.
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,
);
}
}
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 ❌
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.
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,
);
}
}
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,
);
}
}
$this->validate([
'password' => ['required', Password::defaults()],
]);
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() }}"
/>
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,
);
}
}
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
}
}
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Model::unguard();
}
}
$fillable
or
$guarded
on your models.
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Model::shouldBeStrict();
}
}
!app()->isProduction()
means you catch bugs loudly in dev/staging without risking exceptions in production.
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Model::automaticallyEagerLoadRelationships();
}
}
with()
needed.
preventLazyLoading()
— you get automatic N+1 prevention without having to remember to eager load everything up front.
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 |
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
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
MorphMapGenerator::resolveUsing(
fn (Model $model): string => $model->getTable()
);
}
}
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
URL::forceHttps(app()->isProduction());
}
}
route('home'); // https://example.com
url('/about'); // https://example.com/about
class HttpsRedirect
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->isSecure() && app()->isProduction()) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
}
return Application::configure()
->withMiddleware(function (Middleware $middleware): void {
$middleware->append([
HttpsRedirect::class,
]);
})->create();
composer require laravel/pint --dev
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
Rector is a PHP refactoring tool that can automatically upgrade your code to use modern PHP features and best practices.
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,
);
composer require driftingly/rector-laravel --dev
There's also a Laravel-specific extension for Rector that adds a bunch of Laravel-specific rules .
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,
]);
{
"scripts": {
"lint": [
"rector",
"pint --parallel"
]
}
}
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/*
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.
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"
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
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
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.
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
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
composer require tomasvotruba/bladestan --dev
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.
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
composer require pestphp/pest --dev
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.
arch()->preset()->php();
arch()->preset()->security();
arch()->preset()->laravel();
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
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
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.
{
"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"
]
}
}
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
{
"scripts": {
"build": "vite build",
"dev": "vite",
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix"
}
}
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
{
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-blade",
"prettier-plugin-tailwindcss"
],
"overrides: [
{
"files": [
"*.blade.php"
],
"options": {
"parser": "blade"
}
}
]
}
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
{
"scripts": {
"build": "vite build",
"dev": "vite",
"test": "vitest run",
"test:watch": "vitest"
}
}
import { test, expect } from 'vitest'
test('that true is true', () => {
expect(true).toBe(true)
})
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.
class AppServiceProvider extends ServiceProvider
{
public function boot()
{
Vite::useAggressivePrefetching();
}
}
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()->extend(TestCase::class)
->beforeEach(function () {
$this->withoutVite();
});
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
Yarn
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.
Yarn
bun install # Drop-in replacement for npm install
bun run dev # Way faster script execution
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
25.8.0
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
{
"scripts": {
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update"
]
}
}
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' : '' }}"
/>
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>
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>
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');
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');
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');
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
Spatie's laravel-error-solutions package enhances Laravel's error pages with extra actionable information to help you diagnose and fix problems faster.
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.
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.
return [
// ...
'editor' => env('APP_EDITOR', 'phpstorm'),
];
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.
Context::add('url', $request->url());
Context::add('trace_id', Str::uuid()->toString());
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
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();
});
}
};
sleep(2);
Sleep::for(2)->seconds();
Sleep::for(300)->milliseconds();
Sleep::until(now()->addSeconds(30));
pest()->extend(TestCase::class)
->beforeEach(function () {
Sleep::fake();
});
test('we dont wait for 2 seconds', function () {
invoke_code_that_calls_sleep();
Sleep::assertSleptTimes(0);
Sleep::assertNeverSlept();
});
pest()->extend(TestCase::class)
->beforeEach(function () {
Http::fake([
'api.example.com/*' => Http::response(['data' => 'value']),
]);
Http::preventStrayRequests();
});
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();
});
pest()->extend(TestCase::class)
->beforeEach(function () {
Str::createRandomStringsNormally();
Str::createUlidsNormally();
Str::createUuidsNormally();
});
“Good documentation is like a love letter to your future self.”
Anita Pari
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.org
If you need contribution guidelines, particularly for open source projects, the contributor covenant can be a good starting point.
choosealicense.com
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.
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.
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
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.
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.
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.
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));
}
}
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;
}
@view-transition {
navigation: auto;
}
/** Add a view transition name to common elements */
.header {
view-transition-name: header;
}
<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>
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
p {
text-wrap: pretty;
}
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.
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.
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.
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.
p {
hanging-punctuation: first last;
}
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
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.
img {
max-width: 100%;
height: auto;
vertical-align: middle;
font-style: italic;
background-repeat: no-repeat;
background-size: cover;
shape-margin: 1rem;
}
Here's a great CSS reset for images. Let's walk through each property and see what it does visually.
img {
max-width: 100%;
height: auto;
vertical-align: middle;
font-style: italic;
background-repeat: no-repeat;
background-size: cover;
shape-margin: 1rem;
}
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.
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
Some text below
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.
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
Some text below the image
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.
img {
max-width: 100%;
height: auto;
vertical-align: middle;
font-style: italic;
background-repeat: no-repeat;
background-size: cover;
shape-margin: 1rem;
}
<img src="hero.jpg" style="background-image: url(hero-lowres.jpg)" />
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.
img {
max-width: 100%;
height: auto;
vertical-align: middle;
font-style: italic;
background-repeat: no-repeat;
background-size: cover;
shape-margin: 1rem;
}
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.
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.
:root {
accent-color: #ff6600;
}
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.
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.
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.
@if(app()->isLocal())
@vite('resources/css/dev.css')
@endif
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.
img:not([alt]) {
outline: 3px dotted red;
}
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.
“The enemy of art is the absence of limitations.”
Orson Welles