PHP & Laravel Development
Scoped to framework-level PHP. Work on php-src internals or a native PHP extension is C, not PHP: the
skill covers it, including the Zend API conventions (
arginfo, the request-scoped allocator, custom object handlers,
).
Code Style
- in every file
- Happy path last -- guards and errors first, success at the end. Early returns, no .
- Comments explain why, never what. Never comment tests. If code needs a "what" comment, rename or restructure.
- No single-letter variables -- not , not
- not . Always specify . Import classnames, never inline FQN.
- Validation uses array notation for easier custom rule classes
- PHPStan level 8+ (
phpstan analyse --level=8
); aim for 9 on new projects. / for generic collection types. The missing-iterable-value-type check lands at level 6 (and every level above it), so any project at 8+ inherits it: use the generic form on every iterable -- @return Collection<int, User>
, @param array<int, MyObject>
-- and array-shape notation array{first: SomeClass, second: SomeClass}
for fixed-key returns; a bare or will not clear it.
Modern PHP (8.4)
Use when applicable -- no explanatory comments for these in generated code:
- Readonly classes/properties for immutable data; constructor promotion with readonly
- Enums with methods and interfaces for domain constants
- Match expressions over switch
- First-class callable syntax
- Fibers for cooperative async when Swoole/ReactPHP not available
- DNF types
(Stringable&Countable)|null
for 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
- without parentheses in chains:
new MyService()->handle()
- , , -- native array search/check without closures wrapping Collection
Laravel Architecture
- Escalate structure only when it pays for itself. Simple CRUD → a fat Eloquent model + Form Request is correct; do not add layers. Reach for an Action class when an operation crosses model boundaries or gains a 3rd caller. Extract a non-Eloquent domain object only when a business rule needs testing without booting the DB, or protects an invariant the model can't. Default down the ladder, not up -- an unused abstraction is a defect, not foresight.
- Thin controllers -- only validate, call service/action, return response. Domain behavior (scopes, accessors, relationships) lives in models; cross-cutting orchestration in service classes.
- Never call outside . Wherever has run (the deploy sequence requires it, so typically production), every call outside a config file returns -- silently, with no error. Read through
config('services.github.token')
and put third-party credentials in rather than inventing a new config file.
- Service classes for business logic with readonly DI:
__construct(private readonly PaymentService $payments)
- Action classes (single-purpose invokable) for operations crossing service boundaries
- Form Requests for all validation -- never inline in controllers, never inside services. Add so services receive typed, pre-validated data; internal code trusts that input was validated at the boundary.
- Conditional validation: , ,
- Events + Listeners for side effects (notifications, logging, cache invalidation) -- not in services. Name events past-tense in business terms (, not ). Carry IDs and changed facts in the payload, not the full Eloquent model -- re-fetches by key when a queued listener runs, so a model passed in-memory goes stale (same desync class as the observer/stale-copy pitfall below).
- Feature folder organization over type-based past ~20 models
Production Resilience
- Fail-fast config validation in a service provider's : missing API keys, invalid DSNs, misconfigured queues crash on startup, not on the first request that hits the code path.
- Health endpoints: (shallow, 200 if the process responds) and (deep -- checks DB, Redis, critical services).
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)
-- index/store/show/update/destroy without create/edit
Migrations
- Anonymous class migrations; plural table names matching model convention
- Foreign keys:
$table->foreignId('user_id')->constrained()->cascadeOnDelete()
. Always index foreign keys and frequently filtered columns.
- Down method: rollback logic or for new tables
- Separate schema and data migrations -- backfills in their own migration file, not mixed with DDL
- Renames/removals use expand-contract: add new column → backfill → switch reads → drop old (full pattern in skill)
- Never edit a migration that has run in a shared environment -- write a new one
- Set
public $withinTransaction = false;
for per-row commit/lock-release (resumable backfills) or statements Postgres rejects inside a transaction (CREATE INDEX CONCURRENTLY
, ). Otherwise inner loops become savepoints, not independent commits (pitfalls-deep.md); no-op on MySQL.
- resets only the SQL connection -- external stores (DynamoDB, S3, Redis) persist across it, so external-store data migrations re-run on already-migrated data and must be idempotent on a second run.
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([...])
-- never load into memory to update. / for counters.
- Composite indexes for common query combinations
- for large datasets, lazy collections for memory-constrained processing
- Query scopes (, ) for reusable constraints
- / -- never load relations just to count
->when($filter, fn($q) => $q->where(...))
for conditional query building
DB::transaction(fn() => ...)
-- automatic rollback on exception
Model::upsert($rows, ['unique_key'], ['update_cols'])
for bulk insert-or-update
- / with query for automatic stale record cleanup
- is a mass assignment vulnerability -- always explicit
API Resources
- for relationships -- prevents N+1 in responses
- / for permission-based fields; for pivot data
- for custom headers, for metadata (version, pagination)
API Design
- Contract-first: define the API Resource (response contract) and Form Request (input contract) before writing the controller.
- Never return raw models or from controllers -- Resources control exactly what's serialized. Every observable field, ordering, or timing becomes a caller dependency (Hyrum's Law).
- Add, don't modify: new fields/endpoints over changing or removing existing ones. Deprecate first ( in OpenAPI/docblock), remove in a later version.
- Consistent envelope:
{ "success": bool, "data": ..., "error": null, "meta": {} }
. Normalize , , , and application errors to { "success": false, "error": { "code": "...", "message": "..." } }
in the exception handler -- callers build error handling once.
- Isolate third-party SDKs behind an adapter class. Catch vendor exceptions (
GuzzleHttp\Exception\ClientException
, ) inside the adapter and rethrow as domain exceptions () -- never let a Guzzle/Stripe exception bubble into a controller or service.
- Never return the raw vendor object (, a Guzzle ) from an adapter -- map it to a DTO first. Otherwise every vendor field becomes a caller dependency (Hyrum's Law), same as returning raw models on egress.
- Third-party responses are untrusted data: validate shape and content through the DTO before use in logic or rendering. Inject the specific client/credentials the adapter needs, not the whole config or container.
Queues & Jobs
- Batching:
Bus::batch([...])->then()->catch()->finally()->dispatch()
; chaining: Bus::chain([new Step1, new Step2])->dispatch()
- Rate limiting:
Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)
- interface to prevent duplicate processing -- it is a de-duplication hint, not an at-least-once guarantee. When the lock is already held the dispatch is silently discarded: no job queued, no exception, no log line, and returns normally. Where the skip is user-visible (a re-clicked "regenerate report" that produces nothing), check the lock before dispatching and surface the state. A
Illuminate\Queue\Events\UniqueJobSkipped
event exists on the branch but had not landed in a tagged release as of 13.24 -- confirm it is in the installed version before listening for it
- Always handle failures -- implement on jobs
Testing (PHPUnit)
Diagnosing failing tests
- Run the single failing test in isolation (
phpunit --filter test_name
) before reading app code.
- Passes solo but fails in the suite → suspect shared state: container singletons, statics, residue, DB state leaking between tests (the classic paratest failure).
- Diff expected vs actual output before hypothesizing a cause.
- Decide explicitly: test-bug or code-bug. Name which before editing either.
- Never weaken an assertion to make it pass.
Test throws
MissingAttributeException
→ strict mode (
) + factory omits a column with a DB default: add the column to the factory or
after create.
Patterns
- Feature tests (): HTTP through the full stack (, ) -- default for anything touching routes, controllers, or models. Unit tests (): isolated services, actions, value objects.
- for full migration reset per test; for transaction-wrap (faster, no migration testing); to run and rollback per test
- Model factories for all test data -- never raw inserts
- One behavior per test. Name with prefix:
test_user_can_update_own_profile
- Assert both response status AND side effects (DB state, jobs, notifications): /
- for auth,
Sanctum::actingAs($user, ['ability'])
for API auth
- Fake facades BEFORE the action: → act → ; same for
Http::fake(['host/*' => Http::response(...)])
→
Gate::forUser($user)->allows('update', $post)
for authorization assertions
- Coverage target: 80%+ with or in CI
Generic test discipline (anti-patterns, mock rules, rationalization resistance):
skill. Laravel testing deep dives: see References below.
Common Pitfalls
Real production footguns, invisible to PHPStan and feature tests alone. Extended mechanics and alternatives in pitfalls-deep.md.
Query-builder silently skips observers and audit events. Model::query()->where(...)->update([...])
and
fire no model events -- observers, Auditable traits,
all bypassed. Fix:
in a transaction keeps events firing; raw mass update only with a
// intentionally bypasses <Observer>
comment.
Observer cleanup at parent scope nukes siblings. Storage::deleteDirectory($parent->uploadPath)
on a single child delete wipes storage for all siblings while their rows still point at the keys. Detection: when a single-row
has an Observer, check whether its hooks operate at parent or row scope. Fix: scope cleanup to the row's own paths, or move it to an Action that knows the sibling count.
chunkById + json_decode + mutate + json_encode + update
loses concurrent writes on jsonb columns. Any user save between the SELECT and the per-row UPDATE is silently overwritten. Fix: in-place
DB::raw("jsonb_set(...)")
for shallow edits, or
inside the chunk; the decode/encode default is only safe with writes blocked.
cast format only reaches , NOT . A resource returning the raw attribute emits Carbon's ISO 8601, ignoring the cast -- so a cast-format change is not a wire-format change unless the path uses
directly (Filament, DTOs,
). Verify with a live reproducer before flagging.
Nested-array validation accepts scalar elements when only rules are set. 'items.*.name' => 'string'
does not enforce that each
is an array -- scalars pass, then
$data['items'][0]['name']
yields
(blank row) or a
(500). Always pair per-key rules with
.
prevents run-on-rollback but does NOT retry post-commit failures. Default fix: dispatch a queued job with
+
that reverts the DB precondition. Alternatives in
pitfalls-deep.md.
Observer writes a model the caller also holds → stale in-memory copy; the caller's later silently re-clobbers. Fix:
after the triggering event, or
when the caller owns the column.
/ / / are query-builder writes -- no pivot model events fire. Observers and audit traits record nothing. Fix: make the pivot a real
model (
->using(PivotModel::class)
) and write through it with
firstOrCreate(...)->fill([...])->save()
.
is today at 20:20, not year 2020 -- a bare 4-digit string parses as
time-of-day, breaking
/
/
on year-only input. Fix:
Carbon::createFromFormat('Y', $year)->startOfYear()
+ partial-date-aware rules; when migrating a field's validator type, audit its sibling validators for the same incompatibility.
Discipline
- Simplicity first -- every change as simple as possible, minimal code impact
- Only touch what's necessary -- no unrelated changes
- No hacky workarounds -- if a fix feels wrong, step back and implement the clean solution
- New abstraction requires 3+ usage sites; otherwise inline it
- No empty catch blocks -- log or rethrow, never swallow
- Verify before declaring done:
./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit
with zero warnings
Production Performance
OPcache + JIT + preloading configuration and Laravel deploy caches (
,
, etc.):
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, deploy caches
- pitfalls-deep.md -- afterCommit alternatives, observer-desync mechanics, jsonb race, savepoint mechanics