shaxzodbek-uzb/laravel-guardrails

๐Ÿ›ก๏ธ Production guardrail skills for AI-assisted Laravel โ€” stop cross-tenant leaks, N+1, destructive migrations before your AI agent ships them. For Claude Code, Cursor & 40+ agents.

ๅฏพๅฟœโœ“Claude Code~Codex CLIโœ“Cursor
npx skills add shaxzodbek-uzb/laravel-guardrails

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

ใƒ‰ใ‚ญใƒฅใƒกใƒณใƒˆ

Pest Testing Discipline

Features shipped untested โ€” or "tested" with over-mocked suites that assert implementation details โ€” break silently in production and rot on every refactor. This skill makes you write Pest 3/4 tests that prove behavior, cover the unhappy path and authorization, and survive refactoring.

The footgun

Two failure modes, both expensive:

  1. No tests / happy-path-only tests. The feature works in the demo, then in production a guest hits the endpoint, a tenant reads another tenant's data, a validation gap lets garbage in, or a 404 becomes a 500. Authorization holes (403/404 paths that were never asserted) are how data leaks ship to prod.
  2. Brittle, over-mocked tests. You mock your own UserRepository/service with shouldReceive('save')->once(), so the test passes even when the real query is wrong, and it explodes the moment anyone renames a method. The suite is green, gives false confidence, and is deleted in frustration during the next refactor โ€” leaving you back at mode 1.

The fix is the same discipline either way: fake the boundaries you don't own, exercise the code you do own, and assert observable outcomes (DB rows, HTTP status, dispatched jobs/mail) โ€” including the failure and authorization outcomes.

Rules

  1. Write the test with the feature, not "later". A controller/job/command/policy change is incomplete until it has a Pest test. Never report a feature done without a corresponding test asserting its behavior.
  2. Use Pest idioms. Define cases with it('...') or test('...'); assert with the fluent expect(...); share setup with beforeEach(). Do not write PHPUnit class FooTest extends TestCase unless the file already uses that style.
  3. Pick the right type. HTTP/end-to-end behavior โ†’ Feature test (boots the framework, hits routes via $this->get/post/...). Pure logic with no framework/DB โ†’ Unit test. Default to a Feature test when in doubt โ€” it catches more.
  4. Reset the database with a trait, via uses(). Apply RefreshDatabase (transaction-wrapped) or LazilyRefreshDatabase (only migrates when a test touches the DB) in tests/Pest.php for the whole Feature directory, or uses(RefreshDatabase::class) at the top of a file. NEVER leave DB tests dependent on leftover state.
  5. Build data with factories, never hand-rolled inserts. Use User::factory()->create() / ->make() and relationship factories. Hand-built DB::table()->insert([...]) or new Model([...])->save() in tests is brittle and skips casts/defaults โ€” ban it.
  6. Deduplicate with datasets, not copy-paste. When the same assertion runs over many inputs (valid/invalid payloads, role matrices), use ->with([...]) (inline or a named dataset in tests/Datasets/) instead of N near-identical tests.
  7. Fake the boundaries you don't own. For anything external or async, use the facade fakes before the action: Http::fake(), Queue::fake(), Bus::fake(), Mail::fake(), Notification::fake(), Event::fake(), Storage::fake(). For time, use $this->travel(...), $this->travelTo(...), or $this->freezeTime() โ€” never assert against an un-frozen now().
  8. Do NOT mock your own models/services into meaninglessness. Avoid Mockery::mock(MyService::class)->shouldReceive(...) and Model::shouldReceive(...) for code under test. Let the real code run against the test DB and the faked boundaries. Mock your own class only at a genuine seam you are not exercising (e.g. a slow third-party SDK wrapper).
  9. Assert outcomes, not internals. Prefer assertDatabaseHas / assertDatabaseMissing / assertModelExists, assertStatus / assertOk / assertCreated, assertJson / assertJsonPath, assertRedirect, Mail::assertSent, Notification::assertSentTo, Queue::assertPushed, Bus::assertDispatched, Storage::disk()->assertExists. Do not assert "method X was called once" as a proxy for behavior.
  10. Always test the unhappy path AND authorization. Every endpoint/action needs, at minimum: the happy path, a validation/failure path, and the auth paths โ€” guest โ†’ 401/redirect, wrong user/role โ†’ 403, missing/foreign resource โ†’ 404. For any security-sensitive feature add an explicit "another user cannot access/modify this" test. This is non-negotiable for anything touching ownership or tenancy.
  11. Add architecture tests as cheap, global guardrails. Keep a tests/Arch.php (or arch cases in Pest.php) with the presets that fit: arch()->preset()->php() (bans die/var_dump/dd-style debug output and deprecated PHP functions), arch()->preset()->security() (bans eval, extract, unserialize, weak hashing/md5/sha1, insecure randomness, etc.), and arch()->preset()->laravel() (enforces Laravel conventions โ€” including no env() outside config). Add project invariants like arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller').
  12. One behavior per test, descriptive name. it('blocks guests from the dashboard'), not it('works'). If a test name needs "and", split it.
  13. Gate coverage in CI. Run ./vendor/bin/pest --coverage --min=NN to enforce a minimum, and ./vendor/bin/pest --type-coverage (requires the type-coverage plugin) to enforce typed signatures. Treat new untested lines as a defect.
  14. Browser/E2E sparingly (Pest 4). Pest 4 ships browser testing via visit('/')->...->assertSee(...) (Playwright-backed). Reserve it for true full-stack/JS flows; do not reimplement HTTP-layer assertions as slow browser tests.

Good vs bad

// tests/Feature/UpdatePostTest.php

use App\Models\Post;
use App\Models\User;

// โŒ over-mocked: tests that a method was called, not what happened.
//    Passes even if the wrong post is updated or auth is missing.
it('updates a post', function () {
    $service = Mockery::mock(App\Services\PostService::class);
    $service->shouldReceive('update')->once()->andReturnTrue();
    $this->app->instance(App\Services\PostService::class, $service);

    $this->put('/posts/1', ['title' => 'New'])->assertOk();
});
// โœ… exercises real code against the test DB; asserts the outcome AND authz.
use App\Models\Post;
use App\Models\User;

it('lets the owner update their post', function () {
    $owner = User::factory()->create();
    $post  = Post::factory()->for($owner)->create(['title' => 'Old']);

    $this->actingAs($owner)
        ->put("/posts/{$post->id}", ['title' => 'New'])
        ->assertRedirect();

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New']);
});

it('forbids a non-owner from updating the post', function () {
    $post     = Post::factory()->create(['title' => 'Old']);
    $attacker = User::factory()->create();

    $this->actingAs($attacker)
        ->put("/posts/{$post->id}", ['title' => 'Hacked'])
        ->assertForbidden(); // 403

    $this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Old']);
});

it('blocks guests', function () {
    $post = Post::factory()->create();

    $this->put("/posts/{$post->id}", ['title' => 'New'])
        ->assertRedirect('/login'); // or assertUnauthorized() for an API
});
// โŒ hits the real network (flaky, slow, may leak keys); asserts nothing useful.
it('notifies slack', function () {
    $resp = (new App\Services\Slack)->ping('deploy ok');
    expect($resp)->toBeArray();
});
// โœ… fake the boundary you don't own; assert the request you sent.
use Illuminate\Support\Facades\Http;

it('posts the deploy message to slack', function () {
    Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);

    (new App\Services\Slack)->ping('deploy ok');

    Http::assertSent(fn ($request) =>
        $request->url() === 'https://hooks.slack.com/services/T/B/x'
        && $request['text'] === 'deploy ok'
    );
});
// โŒ copy-pasted near-identical validation tests.
it('rejects empty email', function () {
    $this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejects bad email', function () {
    $this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// โœ… one test, table-driven with a dataset.
it('rejects invalid emails', function (string $email) {
    $this->post('/register', ['email' => $email])
        ->assertSessionHasErrors('email');
})->with([
    'empty'       => '',
    'no at-sign'  => 'nope',
    'no domain'   => 'a@',
]);
// โœ… async + time boundaries faked; assert the dispatch and the side effect.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;

it('queues a reminder and welcomes the user on signup', function () {
    Queue::fake();
    Mail::fake();
    $this->freezeTime();

    $this->post('/register', [
        'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
    ])->assertRedirect();

    Queue::assertPushed(SendReminder::class);
    Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
    $this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php โ€” โœ… cheap global guardrails
arch()->preset()->php();        // bans die/var_dump/dd-style debug output + deprecated PHP fns
arch()->preset()->security();   // bans eval, extract, unserialize, md5/sha1, weak randomness, etc.
arch()->preset()->laravel();    // enforces Laravel conventions, incl. no env() outside config

arch('controllers extend the base controller')
    ->expect('App\Http\Controllers')
    ->toExtend('App\Http\Controllers\Controller');

arch('models live in the right namespace')
    ->expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model');

How to verify

Run these before claiming a feature + its tests are done:

# 1. The whole suite must be green.
./vendor/bin/pest

# 2. Run just the new/changed file(s) while iterating.
./vendor/bin/pest tests/Feature/UpdatePostTest.php

# 3. Enforce a coverage floor (requires Xdebug or PCOV).
./vendor/bin/pest --coverage --min=80

# 4. Type coverage (requires pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100

# 5. Style stays clean.
./vendor/bin/pint --test

Then audit that you did NOT reintroduce the footgun:

# Authz coverage: each protected feature test should assert a 401/403/404 path.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\((401|403|404)\)" tests/

# Over-mocking smell: mocking your OWN app classes is a red flag โ€” review each hit.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/

# Hand-rolled inserts instead of factories โ€” replace with ->factory().
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/

# External boundaries must be faked โ€” confirm fakes exist where code calls out.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/

# The security + php arch presets (the cheap global guardrails) must be present somewhere.
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/

Assert in tests, not just code: for every new endpoint confirm there is (a) a happy-path outcome assertion (assertDatabaseHas/assertOk/assertJsonPath), (b) at least one failure/validation assertion, and (c) the guest + wrong-user authorization assertions. If any is missing, the feature is not done.

When it's OK to bend the rule

  • Mocking your own code at a true external seam. A thin wrapper around a paid third-party SDK (SMS, payment gateway) that you cannot fake at the HTTP layer is a legitimate Mockery target โ€” you are isolating their dependency, not faking the behavior under test.
  • Unit-testing pure logic without the DB. Value objects, formatters, and calculators need no RefreshDatabase and no factories โ€” instantiate them directly and expect() the result; that is faster and correct.
  • make() over create() when a test never persists โ€” User::factory()->make() avoids a DB write for pure-logic assertions.
  • Lower coverage thresholds on legacy code. Set a realistic --min and ratchet it up over time rather than blocking all work; never let "100% or nothing" become an excuse to ship zero tests.
  • Browser tests for genuinely JS-driven flows (Livewire/Inertia/Vue interactions, file pickers) โ€” there, visit() is the right tool, not an over-reach.

References

้–ข้€ฃใ‚นใ‚ญใƒซ

CelestoAI/SmolVM

Open-source AI sandbox infrastructure for code execution, browser use, and AI agents.

community

hireamino/amino-skills

Free tools to assess and benchmark email sending domains, from Amino (hireamino.com).

community

coreyhaines31/onboarding

When the user wants to optimize post-signup onboarding, user activation, first-run experience, or time-to-value. Also use when the user mentions "onboarding flow," "activation rate," "user activation," "first-run experience," "empty states," "onboarding checklist," "aha moment," "new user experience," "users aren't activating," "nobody completes setup," "low activation rate," "users sign up but don't use the product," "time to value," or "first session experience." Use this whenever users are signing up but not sticking around. For signup/registration optimization, see signup. For ongoing email sequences, see emails.

community

marceloeatworld/nixos-ai-skill

Auto-updated NixOS & Nix ecosystem documentation for AI coding assistants โ€” works with 33+ tools via the Agent Skills standard (SKILL.md)

community

Code-and-Sorts/awesome-copilot-agents

โœจ A curated list of awesome GitHub instructions, prompt, skills, MCPs and agent markdown files for enhancing your GitHub Copilot AI experience.

community

coreyhaines31/form-cro

When the user wants to optimize any form that is NOT signup/registration โ€” including lead capture forms, contact forms, demo request forms, application forms, survey forms, or checkout forms. Also use when the user mentions "form optimization," "lead form conversions," "form friction," "form fields," "form completion rate," "contact form," "nobody fills out our form," "form abandonment," "too many fields," "demo request form," or "lead form isn't converting." Use this for any non-signup form that captures information. For signup/registration forms, see signup-flow-cro. For popups containing forms, see popup-cro.

community