Skip to content
Go back

I Just Tried the New laravel-best-practices Skill

The Laravel Boost team just shipped a new skill called laravel-best-practices. It checks your code against 100+ Laravel best practices — Eloquent, caching, queues, authentication — and flags anything that doesn’t follow conventions.

I ran it against Growth Method and worked through every finding. Here’s what it caught, what I fixed, and what I learned.

TLDR: it found 25+ issues across 7 categories. I fixed them all in one session.

Table of contents

Open Table of contents

What is Laravel Boost?

Laravel Boost is an MCP server that gives your AI coding agent direct access to your Laravel app — database schema, error logs, browser logs, and version-specific documentation search. It ships with built-in skills that activate automatically based on the packages in your composer.json.

To install or update Laravel Boost:

composer update laravel/boost
php artisan boost:install

You can view the laravel-best-practices skill definition and the full rules files on GitHub.

What the skill covers

The skill checks your code against 20 rules files:

RuleWhat it checks
advanced-queriesSubquery selects and dynamic relationships to avoid N+1
architectureSingle-purpose Action classes and constructor dependency injection
blade-views$attributes->merge(), @pushOnce, Blade components over @include
cachingCache::remember(), Cache::flexible() for stale-while-revalidate, Cache::memo()
collectionsHigher-order messages, cursor() vs lazy() vs lazyById(), toQuery()
configenv() only in config files, encrypted env for secrets
db-performanceEager loading to prevent N+1, constraining eager loads to needed columns
eloquentCorrect relationship types with return type hints, local scopes
error-handlingException reporting/rendering on the class vs centralised in bootstrap/app.php
events-notificationsEvent discovery, event:cache, ShouldDispatchAfterCommit, queued notifications
http-clientExplicit timeout() and connectTimeout(), service-specific Http::macro() clients
mailShouldQueue on Mailables, afterCommit(), Markdown mailables
migrationsArtisan-generated migrations, constrained() foreign keys, never modifying deployed migrations
queue-jobsretry_after > timeout, exponential backoff for retries
routingImplicit route model binding, scoped bindings for nested resources
schedulingwithoutOverlapping(), onOneServer(), runInBackground(), Schedule Groups
securityMass assignment protection with $fillable, authorising every action
styleLaravel naming conventions, shorter readable syntax (helpers over facades)
testingLazilyRefreshDatabase, model assertions, factory states, Exceptions::fake()
validationForm Request classes over inline validation, array notation for rules

How the audit worked

I asked Claude Code to run a full audit of my app against the laravel-best-practices skill. It broke the work into six parallel checks:

  1. Security$fillable/$guarded, raw SQL, $request->all(), file uploads, env() misuse
  2. DB Performance — N+1 queries, lazy loading, SELECT *, queries in Blade
  3. Validation & Routing — inline validation, Form Requests, route definitions, controller size
  4. Eloquent & Caching — relationship types, casts, caching patterns, config
  5. Queues, Events & Testing — job config, event patterns, test traits, error handling, scheduling
  6. Architecture & Style — action classes, HTTP client, migrations, Blade patterns, helpers

The results came back as a single prioritised report with seven priorities, ranked by impact.

What it found (and what I fixed)

Priority 1: Security

TLDR: Without $fillable, if someone passes raw request data to create() or update(), they could set columns you didn’t intend. It’s a defence-in-depth measure — even if your current code is safe, $fillable protects against future mistakes.

The audit found four models without $fillable arrays — meaning all columns were mass-assignable.

Added $fillable arrays to four models:

// app/Models/FileUpload.php
protected $fillable = [
    'filename', 'url', 'type', 'file_size', 'ideas_id', 'team_id',
];

Priority 2: Database performance

TLDR: If your code loops through 50 campaigns and accesses $campaign->team without eager loading, Laravel silently runs 50 extra database queries. Missing indexes slow down report pages, and loading everything into memory risks crashes as data grows.

The biggest finding was the lack of Model::preventLazyLoading() in development. Without it, N+1 queries happen silently — your app works, just slower than it should. With preventLazyLoading enabled, it throws an exception instead, so you catch it before production.

Added lazy loading prevention to AppServiceProvider:

// app/Providers/AppServiceProvider.php
Model::preventLazyLoading(! $this->app->isProduction());

Added composite database indexes for report queries:

// Migration
$table->index(['team_id', 'date_test_inprogress']);
$table->index(['team_id', 'date_test_complete']);
$table->index(['team_id', 'date_test_analysing']);

Replaced an in-memory array search with database-side queries for campaign navigation:

// Before: loads every ID into memory
$ids = $this->getIdeasQuery()->pluck('id')->toArray();
$currentIndex = array_search($this->idea->id, $ids);

// After: asks the database directly
$nextIdea = (clone $this->getIdeasQuery())
    ->where('id', '<', $this->idea->id)
    ->value('id');

And switched a console command from ->get() to ->chunkById(100, ...) so it doesn’t load every record into memory at once.

Priority 3: HTTP client resilience

TLDR: Without timeouts, a slow external API can freeze your background jobs. Ten seconds is plenty for most API calls.

Every outbound HTTP call was missing explicit timeouts. If a third-party API went slow, queue workers would hang indefinitely.

// Before
Http::withToken($token)->post($url, $data);

// After
Http::timeout(10)->connectTimeout(3)->withToken($token)->post($url, $data);

Priority 4: Queue and event hygiene

TLDR: When a background job fails after three retries, you want to know about it. And when an event fires inside a transaction, you want listeners to wait until the data is actually saved.

Four background jobs had no failed() method. When they exhausted retries, they died silently — no log entry, no notification.

// Added to each job
public function failed(\Throwable $exception): void
{
    Log::error('GenerateDesign job failed', [
        'idea_id' => $this->idea->id,
        'error' => $exception->getMessage(),
    ]);
}

All ten event classes lacked ShouldDispatchAfterCommit. This matters when events fire inside database transactions — without it, a listener might process data that hasn’t actually been committed yet.

// Before
class ExperimentCompleted implements ShouldBroadcast
{
}

// After
class ExperimentCompleted implements ShouldBroadcast, ShouldDispatchAfterCommit
{
}

Priority 5: Eloquent and model hygiene

TLDR: Adding return type hints helps your IDE autocomplete correctly and catches bugs where a scope forgets to return the query builder. Small changes that make the codebase consistent.

Missing return type hints on model scopes and boot() methods. The Idea model had them correct — the rest needed to match.

// Before
protected static function boot()

// After
protected static function boot(): void
// Before
public function scopeIsIdea($query)

// After
public function scopeIsIdea($query): Builder

Priority 6: Architecture and style

TLDR: Laravel provides readable helpers for common operations. Using them instead of raw PHP makes the code more consistent, more testable, and easier to follow.

Replaced manual instantiation with dependency injection, swapped raw PHP string functions for Laravel’s Str helpers, and cleaned up request syntax.

// Before
$domain = substr(strrchr($email, '@'), 1);

// After
$domain = Str::after($email, '@');
// Before
$state = $request->input('state');

// After
$state = $request->state;

Priority 7: Testing

TLDR: LazilyRefreshDatabase only runs migrations when a test actually hits the database, making your test suite faster. Ten test files were overriding it with the slower default.

Ten Jetstream test files overrode the base test class with RefreshDatabase instead of the faster LazilyRefreshDatabase already configured. Removing the override speeds up the test suite.

Final scorecard

PrioritySummary
1. SecurityMass-assignment protection ($fillable)
2. DB PerformanceLazy loading prevention, indexes, N+1 fixes, chunking
3. HTTP TimeoutsExplicit timeouts on all outbound HTTP calls
4. Queue/Event Hygienefailed() methods on jobs, ShouldDispatchAfterCommit on events
5. Eloquent HygieneReturn type hints on scopes and boot() methods
6. Architecture/StyleDependency injection, Str helpers, request syntax
7. TestingRemove redundant RefreshDatabase from Jetstream tests

A note on manual verification

The audit is thorough, but not infallible. One finding flagged unescaped rich text output ({!! !!}) as an XSS risk and recommended adding HTMLPurifier. After investigating, I dropped it from the list.

TipTap (which powers flux:editor in Flux) is built on ProseMirror, which uses a schema-based approach. It only produces HTML for node types it knows about — paragraphs, headings, bold, italic, lists, links, and so on. If a user pastes <script>alert('xss')</script> into the editor, ProseMirror strips the <script> tag because it doesn’t match any registered node type. The schema acts as an allowlist, not a blocklist.

Livewire adds a second layer. Its snapshot checksum system prevents users from tampering with the wire:model value client-side. A user can’t bypass the TipTap editor and inject arbitrary HTML into the Livewire property without Livewire throwing a CorruptComponentPayloadException.

So the {!! !!} is safe in this case. The lesson: always verify findings against your specific stack before adding complexity.

Adding it to my workflow

After working through all seven priorities, I wanted this check to happen automatically. I added an instruction to my CLAUDE.md project file:

After every commit, run the laravel-best-practices skill against the changed files to check for violations before pushing.

Now every commit gets a quick best-practices review before it reaches the remote. It takes seconds and catches things I’d otherwise miss.

The laravel-best-practices skill is a solid addition from the Laravel Boost team. If you’re building with Laravel, run it against your codebase. You’ll find things worth fixing.


Back to top ↑