
Ia Php Laravel
- 3 installs
- 28 repo stars
- Updated August 5, 2026
- iliaal/whetstone
Guides modern PHP 8.4 and Laravel development: architecture, Eloquent, queues, migrations, API resources, and PHPUnit testing.
About
A skill covering PHP 8.4 and Laravel patterns including fat-models/thin-controllers architecture, Eloquent optimization, queues, migration safety, and PHPUnit testing. A developer uses it when building or testing Laravel applications with Eloquent, Blade, artisan, or PHPStan.
- Service/action classes, Form Requests with toDto(), expand-contract migrations
- Eloquent N+1 prevention, job batching, and feature/unit test discipline
Ia Php Laravel by the numbers
- 3 all-time installs (skills.sh)
- Ranked #54 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iliaal/whetstone --skill ia-php-laravelAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 28 |
| Last updated | August 5, 2026 |
| Repository | iliaal/whetstone ↗ |
What it does
Guides modern PHP 8.4 and Laravel development: architecture, Eloquent, queues, migrations, API resources, and PHPUnit testing.
Files
PHP & Laravel Development
Code Style
declare(strict_types=1)in every file- Happy path last -- handle errors/guards first, success at the end. Use early returns; avoid
else. - Comments only explain why, never what. Never comment tests. If code needs a "what" comment, rename or restructure instead.
- No single-letter variables --
$exceptionnot$e,$requestnot$r ?stringnotstring|null. Always specifyvoid. Import classnames everywhere, never inline FQN.- Validation uses array notation
['required', 'email']for easier custom rule classes - Static analysis: run PHPStan at level 8+ (
phpstan analyse --level=8). Aim for level 9 on new projects. Use@phpstan-typeand@phpstan-paramfor generic collection types.
Modern PHP (8.4)
Use these when applicable -- do not add explanatory comments in generated code (Claude and developers know them):
- Readonly classes and properties for immutable data
- Enums with methods and interfaces for domain constants
- Match expressions over switch
- Constructor promotion with readonly
- First-class callable syntax
$fn = $obj->method(...) - Fibers for cooperative async when Swoole/ReactPHP not available
- DNF types
(Stringable&Countable)|nullfor complex constraints - Property hooks:
public string $name { get => strtoupper($this->name); set => trim($value); } - Asymmetric visibility:
public private(set) string $name-- public read, private write newwithout parentheses in chains:new MyService()->handle()array_find(),array_any(),array_all()-- native array search/check without closures wrapping Collection
Laravel Architecture
- Thin controllers -- controllers only: validate, call service/action, return response. Domain behavior (scopes, accessors, relationships) lives in models; cross-cutting orchestration lives in service classes.
- Service classes for business logic with readonly DI:
__construct(private readonly PaymentService $payments) - Action classes (single-purpose invokable) for operations that cross service boundaries
- Form Requests for all validation -- never validate inline in controllers. Add
toDto()method to convert validated data to typed service parameters. - Conditional validation:
Rule::requiredIf(),sometimes,exclude_iffor complex form logic - Events + Listeners for side effects (notifications, logging, cache invalidation). Do not put side effects in services.
- Feature folder organization over type-based when project exceeds ~20 models
Production Resilience
- Fail-fast config validation: validate critical config values in a service provider's
boot()method. Missing API keys, invalid DSNs, or misconfigured queues should crash the app on startup, not on the first request that hits the code path. - Health endpoints: expose
/health(shallow, returns 200 if the process responds) and/ready(deep, checks database, Redis, and critical service connectivity). Use Laravel's built-in health checks (Illuminate\Health) or a simple route that queries each dependency.
Routing
- Scoped route model binding to prevent cross-tenant access:
Route::scopeBindings()->group(fn() => ...) Route::model('conversation', AiConversation::class)for custom binding resolution- API resource routes:
Route::apiResource('posts', PostController::class)-- generates index/store/show/update/destroy without create/edit - Standardized JSON response envelope:
{ "success": bool, "data": ..., "error": null, "meta": {} }
Migrations
- Anonymous class migrations -- no class name collisions
snake_caseplural table names matching model convention- Foreign keys:
$table->foreignId('user_id')->constrained()->cascadeOnDelete() - Always add index on foreign keys and frequently filtered columns
- Down method: include rollback logic or
Schema::dropIfExists()for new tables - Separate schema and data migrations -- data backfills in their own migration file, not mixed with DDL
- Renames/removals use expand-contract: add new column → backfill → switch reads → drop old (see
ia-postgresqlskill for the full pattern) - Never edit a migration that has run in a shared environment -- write a new one
- Migrations default to
public $withinTransaction = true-- on Postgres/SQLite all ofup()runs in one transaction, so a per-rowDB::transaction()loop inside a data backfill becomes nested savepoints, not independent commits. One mid-loop failure rolls back every prior row and locks are held untilup()returns. Setpublic $withinTransaction = false;for genuine per-row commit/lock-release (resumable backfills) or statements Postgres rejects inside a transaction (CREATE INDEX CONCURRENTLY,ALTER TYPE ... ADD VALUE). MySQL auto-commits DDL, so the flag is a no-op there. migrate:freshresets only the SQL connection -- external stores (DynamoDB, S3, Redis) persist across it. A suite runningmigrate:freshagainst a long-lived external container re-runs every external-store data migration on top of already-migrated data, so those migrations must be idempotent on a second run (a "does the destination exist" guard crashes the second time; an upsert can resurrect a key a later rename expected gone)
Eloquent
Model::preventLazyLoading(!app()->isProduction())-- catch N+1 during development- Select only needed columns:
Post::with(['user:id,name'])->select(['id', 'title', 'user_id']) - Bulk operations at database level:
Post::where('status', 'draft')->update([...])-- do not load into memory to update increment()/decrement()for counters in a single query- Composite indexes for common query combinations
- Chunking for large datasets (
chunk(1000)), lazy collections for memory-constrained processing - Query scopes (
scopeActive,scopeRecent) for reusable constraints withCount('comments')/withExists('approvals')for aggregate subqueries -- never load relations just to count->when($filter, fn($q) => $q->where(...))for conditional query buildingDB::transaction(fn() => ...)-- automatic rollback on exceptionModel::upsert($rows, ['unique_key'], ['update_cols'])for bulk insert-or-updatePrunable/MassPrunabletrait withprunable()query for automatic stale record cleanup$guarded = []is a mass assignment vulnerability -- always use explicit$fillable
API Resources
whenLoaded()for relationships -- prevents N+1 in responseswhen()/mergeWhen()for permission-based field inclusionwhenPivotLoaded()for pivot datawithResponse()for custom headers,with()for metadata (version, pagination)
API Design
- Contract-first: define the API Resource and Form Request before writing the controller. The resource is the response contract, the Form Request is the input contract -- implementation follows.
- Hyrum's Law awareness: every observable response field, ordering, or timing becomes a dependency for callers. Use API Resources to control exactly what's serialized -- never return raw models or
toArray()from controllers. - Addition over modification: add new fields/endpoints rather than changing or removing existing ones. Removing a field from an API Resource breaks callers silently. Deprecate first (
@deprecatedin OpenAPI/docblock), remove in a later version. - Consistent error envelope: all exceptions should produce the same
{ "success": false, "error": { "code": "...", "message": "..." } }structure. UseHandler::render()or a custom exception handler to normalizeValidationException,ModelNotFoundException,AuthorizationException, and application errors into one format. Callers build error handling once. - Boundary validation via Form Requests: validate at the HTTP boundary, not inside services. Form Requests with
toDto()ensure services receive typed, pre-validated data. Internal code trusts that input was validated at entry -- no redundant checks scattered through repositories or models. - Third-party responses are untrusted data: validate shape and content of external API responses before using them in logic, rendering, or decision-making. A compromised or misbehaving service can return unexpected types, malicious content, or missing fields. Wrap in a DTO or validate through a dedicated response class before use.
Queues & Jobs
- Job batching with
Bus::batch([...])->then()->catch()->finally()->dispatch() - Job chaining for sequential ops:
Bus::chain([new Step1, new Step2])->dispatch() - Rate limiting:
Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...) ShouldBeUniqueinterface to prevent duplicate processing- Always handle failures -- implement
failed()method on jobs
Testing (PHPUnit)
- Feature tests (
tests/Feature/): HTTP through the full stack. Use$this->getJson(),$this->postJson(), etc. - Unit tests (
tests/Unit/): Isolated logic -- services, actions, value objects. No HTTP, minimal database. - Default to feature tests for anything touching routes, controllers, or models
use RefreshDatabasefor full migration reset per test.use DatabaseTransactionsfor wrapping in transaction (faster, but no migration testing).use DatabaseMigrationsto run and rollback migrations per test.- Model factories for all test data -- never raw
DB::table()inserts - One behavior per test. Name with
test_prefix:test_user_can_update_own_profile - Assert both response status AND side effects (DB state, dispatched jobs, sent notifications)
actingAs($user)for auth,Sanctum::actingAs($user, ['ability'])for API auth- Fake facades BEFORE the action:
Queue::fake()then act thenQueue::assertPushed(...) Http::fake()for outbound HTTP:Http::fake(['api.example.com/*' => Http::response([...], 200)])thenHttp::assertSent(...)Gate::forUser($user)->allows('update', $post)for authorization assertionsassertDatabaseHas/assertDatabaseMissingto verify persistence- Coverage target: 80%+ with
pcovorXDEBUG_MODE=coveragein CI
For generic test discipline (anti-patterns, mock rules, rationalization resistance), see the ia-writing-tests skill — this skill covers Laravel-specific patterns that sit on top of that foundation. See testing patterns and examples for PHPUnit essentials, data providers, and running tests. See feature testing for auth, validation, API, console, and DB assertions. See mocking and faking for facade fakes and action mocking. See factories for states, relationships, sequences, and afterCreating hooks.
Common Pitfalls
Concrete Laravel footguns that recur across projects. Each is a real class of bug caught in production review; all are invisible to PHPStan and feature tests alone.
Query-builder `update()` silently skips observers and audit events. Model::query()->where(...)->update([...]) and Relation::update([...]) are query-builder operations — they do NOT fire Eloquent model events. Any observer registered via #[ObservedBy], OwenIt Auditable trait, or static::saving/updating callback is bypassed. No audit row, no cascading cleanup, no dispatched jobs. Fix: lockForUpdate() + save() inside a transaction gives the same idempotent-atomic semantics while still firing events. Reach for raw mass update only with a // intentionally bypasses <Observer> comment documenting the bypass.
Observer `deleting()` cleanup at parent scope nukes siblings. If a DocumentObserver::deleting() calls Storage::deleteDirectory($parent->uploadPath) and the parent has a hasMany of Documents, deleting one child wipes storage for all siblings while their DB rows remain pointing at non-existent keys. Detection: when any single-row $model->delete() has an Observer, open app/Observers/{Model}Observer.php and check whether deleting() / deleted() hooks operate at parent scope or single-row scope. Fix: scope cleanup to the row's own storage paths, or move cleanup out of the observer into an Action class that knows the sibling count.
`chunkById + json_decode + mutate + json_encode + update` loses concurrent writes on jsonb columns. The window between the SELECT populating $row->metadata and the per-row UPDATE is milliseconds; any user save in that window is silently overwritten by the migration's stale snapshot. Fix: use in-place DB::raw("jsonb_set(metadata, '{path}', ...)") for shallow edits, or lockForUpdate() inside the chunk for arbitrary PHP logic. Default chunkById + decode/encode is only safe during a maintenance window with writes blocked.
`date:<fmt>` cast format only reaches `$model->toArray()`, NOT `JsonResource::resolve()`. A JsonResource that does return ['started_at' => $this->resource->started_at] emits ISO 8601 from Carbon's own JsonSerializable, ignoring the cast format entirely. Changing date to date:m/d/Y is NOT an API contract change unless the code path uses $model->toArray() directly (Filament admin, DTOs pulling from toArray(), direct json_encode($model)). Verify with a live reproducer before flagging as wire-format regression.
*Nested-array validation accepts scalar elements when only `.field rules are set.** Rules like 'items..name' => 'string'` and `'items..date' => 'date' do NOT enforce that each items.` is itself an array. Scalar elements pass validation; the handler's `$data['items'][0]['name']` then yields `null` (string indexed as array — PHP warning, blank row written) or `TypeError` (int indexed as array — 500 to the caller). Always pair per-key rules with an explicit `'items.' => 'array'` constraint.
`DB::afterCommit` closes the rollback half but not the post-commit-failure half. Wrapping an external mutation (S3, search index, third-party webhook) in DB::afterCommit($closure) prevents the external work from running when the transaction rolls back. It does NOT retry the external op when it fails after commit — the closure runs once, exceptions bubble out of the response cycle, the operation drops, and the DB row now advertises a state the external system doesn't reflect. Closing patterns: (a) queued job with tries + exponential backoff + failed(Throwable $e) handler that reverts the DB precondition the job was supposed to make true; (b) external-op-first-then-DB when the op is idempotent on the destination key (works for Storage::copy, fails for Storage::move after first attempt); (c) reconciler scheduled command that walks rows with stuck "in-flight" flags. Pattern (a) is the general-purpose default; queue retry semantics already model the transient/permanent split.
An observer that writes a parent the caller still holds desyncs the caller's in-memory copy. When an observer fires mid-flow (e.g. Document::deleted → $verifiable->update([...])) and mutates a model the caller is also mutating, the two share no state — Eloquent dirty-tracking compares in-memory current vs in-memory original, never the DB. The caller's later save() only writes columns it changed, so a column the observer cleared stays cleared on disk, and a column the caller set back to its in-memory original is seen as not-dirty and never re-written. DB::transaction doesn't help — it's in-memory state, not isolation. Fix: $model->refresh() in the caller after the triggering event and before its later save(), or run the triggering write under Model::withoutEvents(...) when the caller owns that column's semantics for the flow.
`BelongsToMany::attach` / `detach` / `sync` / `updateExistingPivot` are query-builder writes — no pivot model events fire. They emit INSERT/UPDATE/DELETE on the pivot table directly, so observers and event-driven audit traits record nothing, even when the surrounding Action correctly set audit context first. To get events on pivot writes, make the pivot a real Pivot model (->using(PivotModel::class)) and write through it with firstOrCreate(...)->fill([...])->save() instead of attach().
`Carbon::parse('2020')` is today at 20:20, not year 2020 — a bare 4-digit string parses as an `HHMM` time-of-day. Year-month (2020-05) and full dates parse normally; only the bare year is the trap. It silently breaks date-family validators: before_or_equal:today on a year-only value fails because 2020 resolves to today's date at a future clock time. Don't leave before_or_equal:today / after / before on a field that accepts year-only input; use a partial-date-aware rule. When you migrate a field's validator type, audit the sibling validators on that field for the same incompatibility. To build a real date from a year, use Carbon::createFromFormat('Y', $year)->startOfYear().
Discipline
- Simplicity first -- every change as simple as possible, impact minimal code
- Only touch what's necessary -- avoid introducing unrelated changes
- No hacky workarounds -- if a fix feels wrong, step back and implement the clean solution
- Before adding a new abstraction, verify it appears in 3+ places. If not, inline it.
- No empty catch blocks -- log or rethrow, never swallow exceptions
- Verify:
./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunitpass with zero warnings before declaring done
Production Performance
For OPcache + JIT + preloading configuration and Laravel-specific deploy caches (config:cache, route:cache, etc.), load production-performance.md.
References
- laravel-ecosystem.md -- Notifications, Task Scheduling, Custom Casts
- testing.md -- PHPUnit essentials, data providers, running tests
- feature-testing.md -- Auth, validation, API, console, DB assertions
- mocking-and-faking.md -- Facade fakes, action mocking, Mockery
- factories.md -- States, relationships, sequences, afterCreating hooks
- production-performance.md -- OPcache, JIT, preloading, Laravel deploy caches
Factory Patterns
When to read: when writing or refactoring Laravel model factories — basic shapes, states, sequences, relationships, and seed-vs-test boundaries.
Basic Factory
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'slug' => fake()->slug(),
'content' => fake()->paragraphs(3, true),
'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
'user_id' => User::factory(),
'category_id' => Category::factory(),
];
}
}States
Name states as adjectives or past participles -- they describe what the model IS:
public function unpublished(): static
{
return $this->state(fn (array $attributes) => [
'published_at' => null,
]);
}
public function published(): static
{
return $this->state(fn (array $attributes) => [
'published_at' => now(),
]);
}
// Usage
$post = Post::factory()->unpublished()->create();Relationships
// Has many -- creates parent with 3 children
$post = Post::factory()
->has(Comment::factory()->count(3))
->create();
// Belongs to -- creates children for specific parent
$posts = Post::factory()
->count(3)
->for($user)
->create();
// Combined
$post = Post::factory()
->published()
->for($user)
->has(Comment::factory()->count(3))
->has(Tag::factory()->count(2))
->create();afterCreating Hooks
For side effects that require a persisted model:
public function configure(): static
{
return $this->afterCreating(function (Post $post) {
$post->tags()->attach(
Tag::factory()->count(3)->create()
);
});
}Sequences
$users = User::factory()
->count(3)
->sequence(
['role' => 'admin'],
['role' => 'editor'],
['role' => 'viewer'],
)
->create();Usage in Tests
// Single model
$user = User::factory()->create();
// With overrides
$user = User::factory()->create(['email' => 'specific@test.com']);
// Multiple
$posts = Post::factory()->count(10)->create();
// In-memory (no DB write)
$user = User::factory()->make();Always use create() for feature tests (persists to DB). Use make() only for unit tests that need a model instance without persistence.
Feature Testing Patterns
When to read: when writing Laravel feature tests for HTTP, auth, session, file upload, or other request-cycle scenarios.
Authentication Testing
public function test_authenticated_user_can_access_endpoint(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/profile')
->assertOk()
->assertJson(['data' => ['id' => $user->id]]);
}
public function test_guest_receives_401(): void
{
$this->getJson('/api/profile')->assertUnauthorized();
}
// Sanctum with specific abilities
public function test_user_with_wrong_ability_gets_403(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['view-posts']);
$this->postJson('/api/posts', ['title' => 'Test'])
->assertForbidden();
}Authorization Testing
public function test_user_cannot_delete_others_posts(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(); // different user
$this->actingAs($user)
->deleteJson("/api/posts/{$post->id}")
->assertForbidden();
}
public function test_admin_can_delete_any_post(): void
{
$admin = User::factory()->admin()->create();
$post = Post::factory()->create();
$this->actingAs($admin)
->deleteJson("/api/posts/{$post->id}")
->assertNoContent();
$this->assertDatabaseMissing('posts', ['id' => $post->id]);
}Validation Testing
public function test_post_requires_title_and_content(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', [])
->assertUnprocessable()
->assertJsonValidationErrors(['title', 'content']);
}API Response Structure
public function test_returns_paginated_posts(): void
{
Post::factory()->count(30)->create();
$this->getJson('/api/posts')
->assertOk()
->assertJsonStructure([
'data' => [['id', 'title', 'content', 'created_at']],
'meta' => ['total', 'current_page', 'last_page'],
])
->assertJsonCount(15, 'data');
}Fluent JSON Assertions
For complex API responses, use AssertableJson:
use Illuminate\Testing\Fluent\AssertableJson;
public function test_user_api_response_shape(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/profile')
->assertJson(fn (AssertableJson $json) =>
$json->has('data', fn ($j) =>
$j->where('id', $user->id)
->where('email', $user->email)
->whereType('created_at', 'string')
->etc()
)
);
}Database Assertions
public function test_creates_record_with_correct_attributes(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);
$this->assertDatabaseHas('posts', [
'title' => 'Test',
'user_id' => $user->id,
]);
}
public function test_soft_deletes_record(): void
{
$post = Post::factory()->create();
$this->actingAs($post->user)
->deleteJson("/api/posts/{$post->id}");
$this->assertSoftDeleted('posts', ['id' => $post->id]);
}N+1 Query Count Testing
public function test_index_avoids_n_plus_one(): void
{
Post::factory()->count(10)->create();
$this->expectsDatabaseQueryCount(2); // 1 posts + 1 users (eager loaded)
$this->getJson('/api/posts')->assertOk();
}Console / Artisan Command Testing
public function test_inspire_command_succeeds(): void
{
$this->artisan('inspire')->assertSuccessful();
}
public function test_command_output(): void
{
$this->artisan('greet', ['name' => 'Taylor'])
->expectsOutput('Hello, Taylor!')
->assertSuccessful();
}
public function test_interactive_command(): void
{
$this->artisan('make:user')
->expectsQuestion('What is the name?', 'John')
->expectsQuestion('What is the email?', 'john@example.com')
->expectsConfirmation('Are you sure?', 'yes')
->expectsOutput('User created!')
->assertSuccessful();
}
public function test_scheduled_command_runs_daily(): void
{
$events = collect(app(Schedule::class)->events())
->filter(fn ($e) => str_contains($e->command, 'backup:run'));
$this->assertCount(1, $events);
$this->assertSame('0 0 * * *', $events->first()->expression);
}Debugging Helpers
// Show full exception stack trace instead of HTTP error response
$this->withoutExceptionHandling()->get('/broken');
// Follow redirects automatically
$this->followingRedirects()->post('/login', $creds)->assertSee('Dashboard');
// Skip specific middleware for testing
$this->withoutMiddleware(ThrottleRequests::class)->get('/api/posts');Laravel Ecosystem Patterns
When to read: when reaching for ecosystem features — notifications, queues, broadcasting, scheduling, file storage, mail — and needing the canonical Laravel approach.
Notifications
Multi-channel dispatch -- mail, SMS, Slack, database -- from a single notification class.
// Create: php artisan make:notification OrderShipped
class OrderShipped extends Notification implements ShouldQueue
{
use Queueable;
public function via(object $notifiable): array
{
// Channel selection per user preference
return $notifiable->prefers_sms
? ['vonage']
: ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Order Shipped')
->line("Order #{$this->order->id} has shipped.")
->action('Track Order', url("/orders/{$this->order->id}"));
}
public function toArray(object $notifiable): array
{
// Stored in `notifications` table for in-app display
return ['order_id' => $this->order->id, 'status' => 'shipped'];
}
}
// Dispatch
$user->notify(new OrderShipped($order));
// Bulk (uses queue automatically)
Notification::send($users, new OrderShipped($order));- Always implement
ShouldQueue-- notifications are side effects, never block the request - Use
toArray()for database channel -- powers in-app notification feeds - Read:
$user->unreadNotifications, mark:$notification->markAsRead() - Rate limit with
ShouldBeUniqueto prevent notification spam
Task Scheduling
Define recurring tasks in routes/console.php:
// Artisan commands
$schedule->command('reports:generate')->dailyAt('02:00')->withoutOverlapping();
$schedule->command('cache:prune-stale-tags')->hourly();
// Closures for simple tasks
$schedule->call(fn () => DB::table('sessions')->where('last_active', '<', now()->subDay())->delete())
->daily()
->name('cleanup-sessions')
->withoutOverlapping();
// Queue jobs
$schedule->job(new ProcessDailyMetrics)->dailyAt('01:00');Key methods:
->withoutOverlapping()-- prevent concurrent runs (uses cache lock)->onOneServer()-- run only on one server in multi-server setup->evenInMaintenanceMode()-- critical tasks that must run duringphp artisan down->runInBackground()-- don't block scheduler for long tasks->emailOutputOnFailure('ops@example.com')-- alert on failures- Requires system cron:
* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1
Custom Casts
Value objects for model attributes -- encapsulate formatting, validation, and behavior.
class Money implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return new MoneyValue(
amount: (int) $value,
currency: $attributes['currency'] ?? 'USD'
);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return ['price' => $value->amount, 'currency' => $value->currency];
}
}
// Usage on model
protected function casts(): array
{
return ['price' => Money::class];
}Built-in casts to prefer over manual accessors:
AsEncryptedCollection::class-- encrypt JSON columns at restAsEnumCollection::class-- array of enums stored as JSONAsStringable::class-- fluent string operations on attribute- Enum casts:
'status' => OrderStatus::class-- automatic PHP enum <-> DB value - Encrypted cast:
'api_token' => 'encrypted'-- transparent encrypt/decrypt for sensitive fields
Security Hardening
Session
SESSION_HTTP_ONLY=true,SESSION_SAME_SITE=strictin.env- Regenerate session on login:
$request->session()->regenerate()in auth controller SESSION_LIFETIME-- set appropriate timeout (120 min default is often too long)
Security Headers Middleware
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$response->headers->set('Content-Security-Policy', "default-src 'self'");
return $response;
}
}Register in bootstrap/app.php middleware stack.
Password Validation
Password::min(12)->letters()->mixedCase()->numbers()->symbols()Signed URLs
URL::temporarySignedRoute('download', now()->addMinutes(30), ['file' => $id]) with signed middleware for tamper-proof temporary access.
File Uploads
- Validate MIME type:
'file' => ['required', 'mimes:pdf,docx', 'max:10240'] - Store outside public disk:
$request->file('doc')->store('documents', 's3') - Never trust the original filename
Dependency Audit
composer audit -- check for known CVEs in dependencies. Run in CI.
Logging PII
Never log raw user data. Use [REDACTED] pattern for sensitive fields in log context.
Mocking and Faking
Fake facades BEFORE the action that triggers them. Assert AFTER.
Queue Faking
public function test_dispatches_job_on_post_creation(): void
{
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);
Queue::assertPushed(ProcessPost::class, fn ($job) => $job->post->title === 'Test');
Queue::assertPushed(ProcessPost::class, 1); // exact count
}Event Faking
public function test_fires_event_on_publish(): void
{
Event::fake([PostPublished::class]);
$post = Post::factory()->create();
$post->publish();
Event::assertDispatched(PostPublished::class, fn ($e) => $e->post->id === $post->id);
}Notification Faking
public function test_sends_notification_to_post_author(): void
{
Notification::fake();
$post = Post::factory()->create();
$post->approve();
Notification::assertSentTo($post->user, PostApproved::class);
}Mail Faking
public function test_sends_welcome_email(): void
{
Mail::fake();
$this->postJson('/api/register', [
'email' => 'new@example.com',
'password' => 'secret123',
]);
Mail::assertSent(WelcomeMail::class, fn ($mail) => $mail->hasTo('new@example.com'));
}Storage Faking
public function test_uploads_avatar(): void
{
Storage::fake('public');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg');
$this->actingAs($user)
->postJson('/api/avatar', ['avatar' => $file])
->assertOk();
Storage::disk('public')->assertExists("avatars/{$file->hashName()}");
}HTTP Faking (External APIs)
public function test_fetches_data_from_external_api(): void
{
Http::fake([
'api.example.com/*' => Http::response(['data' => ['id' => 1, 'name' => 'Test']], 200),
]);
$service = app(ExternalApiService::class);
$result = $service->fetchData();
$this->assertSame('Test', $result['name']);
Http::assertSent(fn ($request) =>
$request->url() === 'https://api.example.com/data'
&& $request->hasHeader('Authorization')
);
}Use Http::preventStrayRequests() after faking to fail on any unfaked URL -- catches accidental real HTTP calls:
Http::fake(['api.example.com/*' => Http::response(['ok' => true])]);
Http::preventStrayRequests(); // any other URL throws an exceptionBus Faking (Batches & Chains)
public function test_dispatches_batch(): void
{
Bus::fake();
$this->postJson('/api/import', ['file' => $file]);
Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 10);
}
public function test_dispatches_chain(): void
{
Bus::fake();
$this->postJson('/api/process');
Bus::assertChained([ValidateJob::class, ProcessJob::class, NotifyJob::class]);
}Action Testing with resolve() + swap()
For invokable action classes, resolve from the container so DI works. Use swap() to replace dependencies with mocks:
public function test_processes_order_and_notifies(): void
{
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
// Mock dependency action and swap into container
$calculateTotal = Mockery::mock(CalculateOrderTotalAction::class);
$calculateTotal->shouldReceive('__invoke')
->once()
->with($order)
->andReturn(10000);
$this->swap(CalculateOrderTotalAction::class, $calculateTotal);
$notifyAction = Mockery::mock(NotifyOrderCreatedAction::class);
$notifyAction->shouldReceive('__invoke')->once()->with($order);
$this->swap(NotifyOrderCreatedAction::class, $notifyAction);
// resolve() pulls from container with mocked dependencies injected
$result = resolve(ProcessOrderAction::class)($order);
$this->assertSame(10000, $result->total);
}Only mock what you own -- for external services (Stripe, etc.), create a service abstraction with a driver pattern and swap the driver config in tests, instead of mocking the SDK directly.
Mockery (Service Mocking)
For non-facade services where DI mocking is needed:
public function test_sends_notification_to_active_users(): void
{
$repository = Mockery::mock(UserRepository::class);
$repository->shouldReceive('findActive')
->once()
->andReturn(User::factory()->count(2)->make());
$this->app->instance(UserRepository::class, $repository);
$service = app(NotificationService::class);
$result = $service->notifyActiveUsers('Important message');
$this->assertSame(2, $result->count());
}Prefer Laravel facade fakes over Mockery when both options exist. Use Mockery for custom services and repository interfaces.
Time Travel
For testing time-dependent logic (expiration, scheduling, "created N days ago"):
public function test_marks_overdue_orders(): void
{
$order = Order::factory()->create();
$this->travel(31)->days();
$this->artisan('orders:mark-overdue')->assertSuccessful();
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => 'overdue',
]);
$this->travelBack(); // reset to real time
}
public function test_timestamps_match_frozen_time(): void
{
$this->freezeTime();
$user = User::factory()->create();
$this->assertSame(now()->toDateTimeString(), $user->created_at->toDateTimeString());
}
public function test_subscription_expires(): void
{
$user = User::factory()->create();
$subscription = Subscription::factory()->create([
'user_id' => $user->id,
'expires_at' => now()->addDays(30),
]);
$this->travelTo(now()->addDays(31));
$this->assertTrue($subscription->fresh()->isExpired());
}Available time units: seconds(), minutes(), hours(), days(), weeks(), months(), years(). Always call $this->travelBack() or use $this->freezeTime() to avoid leaking time state between tests.
Production Performance — OPcache, JIT, Preloading, Laravel caches
Load this reference when deploying a PHP/Laravel application to production or optimizing runtime performance. Not relevant for dev or testing.
- OPcache: enable in production (
opcache.enable=1), setopcache.memory_consumption=256,opcache.max_accelerated_files=20000. Validate withopcache_get_status(). - JIT: enable with
opcache.jit_buffer_size=100M,opcache.jit=1255(tracing). Biggest gains on CPU-bound code (math, loops), minimal impact on I/O-bound Laravel requests. - Preloading:
opcache.preload=preload.php— preload framework classes and hot app classes. Usecomposer dumpautoload --classmap-authoritativein production. - Laravel-specific:
php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache— run on every deploy.composer install --optimize-autoloader --no-devfor production.
Testing Laravel (PHPUnit)
Use PHPUnit with Laravel's testing helpers. Every test file starts with declare(strict_types=1).
PHPUnit Essentials
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\{User, Post};
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class PostTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', ['title' => 'New Post', 'body' => 'Content']);
$response->assertCreated()
->assertJson(['data' => ['title' => 'New Post']]);
$this->assertDatabaseHas('posts', [
'title' => 'New Post',
'user_id' => $user->id,
]);
}
}Data Providers
Data providers for boundary/validation testing:
#[DataProvider('titleLengthProvider')]
public function test_validates_title_length(string $title, bool $valid): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', ['title' => $title, 'body' => 'Content']);
$valid ? $response->assertCreated() : $response->assertUnprocessable();
}
public static function titleLengthProvider(): array
{
return [
'too short' => ['AB', false],
'minimum valid' => ['ABC', true],
'maximum valid' => [str_repeat('A', 255), true],
'too long' => [str_repeat('A', 256), false],
];
}Running Tests
For large test suites, call PHPUnit directly to avoid artisan's memory overhead:
./vendor/bin/phpunit # all tests (direct, lower memory)
./vendor/bin/phpunit --filter=PostTest # by name
./vendor/bin/phpunit --processes=auto # parallel (PHPUnit 11+)
./vendor/bin/phpunit --coverage-text --min=80 # with coverage threshold
php artisan test # small suites or quick runs
php -d memory_limit=1G artisan test # if artisan needed on large suitesia-php-laravel Specification
Intent
ia-php-laravel is a language-class skill (stack-specific patterns and idioms). Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, PHPUnit, PHPStan, or building/testing PHP applications with frameworks. Not for PHP internals (php-src) or general PHP language discussion.
Scope
In scope:
- Behaviors described in
SKILL.mdand routed via the should_trigger phrasings indistillery/tests/fixtures/triggers/ia-php-laravel.jsonl. - Updates to runtime behavior, structure, trigger precision, references, and validation.
Out of scope:
- Acting as the runtime instructions themselves (those live in
SKILL.md). - Trigger phrasings already covered by adjacent
ia-*skills (validate-pluginflags >70% description overlap as DUPLICATE_TRIGGER). - <!-- to fill in: domain-specific exclusions when the skill drifts -->
Trigger Context
- Class:
language - Hook regex:
plugins/whetstone/hooks/skill-patterns.sh->SKILL_PATTERNS[ia-php-laravel] - Common requests (from fixture should_trigger):
- "add a new Laravel controller for user profiles"
- "fix the Eloquent query performance"
- "create a blade template for the dashboard"
- Should not trigger for (from fixture should_not_trigger):
- "write a React component for the settings page"
- "optimize the PostgreSQL query plan"
- "write a Python CLI tool for data import"
Source And Evidence Model
Authoritative sources:
SKILL.md-- runtime instructions and reference routing.references/*.md-- bundled supplementary content (6 file(s)).distillery/tests/fixtures/triggers/ia-php-laravel.jsonl-- positive and negative trigger phrasings under regression test.plugins/whetstone/hooks/skill-patterns.sh-- regex pattern that fires this skill.distillery/.eval-data/ia-php-laravel/-- harvested session examples (when present).
Data that must not be stored in this skill or its references:
- Secrets, credentials, tokens.
- Machine-specific filesystem paths (
/home/...,/Users/...,~/ai/...). The validator (MACHINE_PATH_LEAK) flags these as HIGH. - Private URLs, customer data, or unredacted personal information.
Coverage matrix
| Dimension | Status | Evidence |
|---|---|---|
| Trigger fixtures | complete | distillery/tests/fixtures/triggers/ia-php-laravel.jsonl (>=5 should_trigger, >=5 should_not_trigger) |
| Hook regex pattern | complete | plugins/whetstone/hooks/skill-patterns.sh (SKILL_PATTERNS[ia-php-laravel]) |
| Reference architecture | complete | 6 file(s) under references/ |
| Real-usage signal | <!-- populated by harvest-sessions when sessions exist --> | distillery/.eval-data/ia-php-laravel/ (created by harvest-sessions) |
Evaluation
Lightweight (run on every change):
python3 distillery/scripts/distiller.py validate-plugin --component ia-php-laravel
python3 distillery/scripts/distiller.py test-triggers --skill ia-php-laravelDeeper (when behavior risk warrants):
python3 distillery/scripts/distiller.py dspy-eval ia-php-laravel
python3 distillery/scripts/distiller.py diagnose-negatives ia-php-laravelAcceptance gates:
validate-plugin --component ia-php-laravelreturns 0 HIGH findings.test-triggers --skill ia-php-laravelreturns F1 = 1.0 with floors of 5 should_trigger and 5 should_not_trigger.- For dspy-eval, the composite score does not regress against the most recent saved baseline (see
distillery/.eval-data/ia-php-laravel/history.json).
Known Limitations
<!-- to fill in over time as drift surfaces. Default rule: any time diagnose-negatives surfaces a recurring failure pattern, document it here so future maintainers understand the trade-off the current implementation accepts. -->
Maintenance Notes
- Update
SKILL.mdwhen the runtime workflow, branch conditions, or output contract changes. - Update this
SPEC.mdwhen intent, scope, evidence model, evaluation gates, or maintenance expectations change. - Update the trigger fixture when adding new positive phrasings, removing stale ones, or expanding scope (the 5/5 floor is a hard validator gate).
- Update the hook regex in
skill-patterns.shwhenever fixture positives expose a missed phrasing; verify F1 = 1.0 witheval-triggersbefore committing. - Run the full release pipeline via
/release-- never bump versions or update CHANGELOG.md from a per-skill edit.