🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

cc-codeconductor

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cc-codeconductor - npm Package Compare versions

Comparing version
0.2.10
to
0.3.0
+119
presets/claude/skills/android/SKILL.md
---
id: android
version: 1.0.0
name: Android + Kotlin (Jetpack Compose & Media3)
description: >
Provides expert knowledge for building modern native Android apps using Kotlin, Jetpack Compose, Material Design 3, MVI architecture, Hilt dependency injection, and Media3/ExoPlayer.
user-invokable: true
license: MIT
metadata:
author: lgzarturo
category: mobile
compatibility:
tools: [claude, codex, gemini, agy, opencode]
stacks:
languages: [kotlin, java]
frameworks: [android]
risk:
level: medium
can_execute_shell: true
can_modify_files: true
requires_network: false
inputs: []
outputs: []
quality:
reviewed_by: codeconductor-core
version: 0.1.0
---
# Android + Kotlin (Jetpack Compose & Media3)
This playbook defines the architecture, UI design system, performance guidelines, and testing strategy for native Android applications.
---
## 1. Lead Architect & Modularization
### Architecture (MVI / MVVM with Clean Architecture)
- Prefer **MVI (Model-View-Intent)** for state management. Ensure a strict unidirectional data flow:
- **UiState**: Single source of truth for the UI state.
- **UiIntent**: Actions initiated by the user or system.
- **UiEffect**: One-time events (navigation, toasts, snackbars).
- Separate the project into clean logical modules:
- `app`: Application entry point, launcher activity, global dependency injection configuration.
- `core`: Shared resources, network layer, media playback logic (ExoPlayer/Media3), storage.
- `feature`: Individual business features (isolated and independent of other features).
### Dependency Injection (Hilt)
- Annotate the Application class with `@HiltAndroidApp`.
- Annotate Activities and Fragments with `@AndroidEntryPoint`.
- Use constructor injection for ViewModels with `@HiltViewModel`.
---
## 2. Jetpack Compose UI/UX
### Recomposition & State Management
- Consume `StateFlow` from ViewModels safely using `collectAsStateWithLifecycle()` to respect the activity lifecycle.
- Keep data models immutable. Mark configuration classes with `@Immutable` or `@Stable` to prevent redundant recompositions.
- Use `contentType` and `key` in `LazyColumn` and `LazyRow` to optimize item recycling and rendering performance.
- Avoid fixed hardcoded margins. Apply `WindowInsets` to support modern edge-to-edge screens.
---
## 3. Performance & Multimedia (Media3 / ExoPlayer)
### Playback & Services
- Run `ExoPlayer` / `Media3` inside a robust `Foreground Service` (using `MediaSessionService`) to ensure playback continues when the app is in the background.
- Clean up resources: release player instances in `onDestroy` or when the service is stopped.
- Control wake locks strictly to avoid draining the user's battery when playback is paused.
### Kotlin Coroutines & Threading
- Always inject `CoroutineDispatcher` instances instead of hardcoding them:
- `Dispatchers.IO`: Database access, network operations, file reading.
- `Dispatchers.Default`: CPU-intensive operations (parsing JSON, filtering large lists).
- `Dispatchers.Main`: UI modifications and light interactions.
### R8 & Resource Optimization
- Enable shrinking and obfuscation in production builds:
- `isMinifyEnabled = true`
- `isShrinkResources = true`
- Convert all static images to the modern `WebP` format to minimize the application binary size.
---
## 4. Quality Assurance & Automated Testing
### Unit Testing
- Use JUnit 5 and MockK for mocking dependencies.
- Test ViewModels and Reducers by asserting StateFlow updates. Use `Turbine` to easily test Kotlin Flows.
- Mock all multimedia/ExoPlayer components to avoid loading actual audio/video resources in unit tests.
### UI Testing
- Create UI tests using Compose test rules: `createComposeRule()` or `createAndroidComposeRule<MainActivity>()`.
- Assert correct rendering, visibility states, and user interactions without executing real network calls.
---
## 5. Key Gradle & ADB Commands
Use these commands for building, checking code style, running tests, and profiling performance:
| Command | Action |
| ------- | ------ |
| `./gradlew init` | Initialize a new Gradle project structure. |
| `./gradlew app:dependencies` | List all dependencies of the application module. |
| `./gradlew assembleDebug` | Validate compilation and generate debug APK. |
| `./gradlew ktlintCheck` | Run KtLint check to enforce Kotlin style guidelines. |
| `./gradlew lintDebug` | Run Android Lint tool to inspect code quality and UI issues. |
| `./gradlew testDebugUnitTest` | Run unit tests for debug build variant. |
| `./gradlew connectedAndroidTest` | Run instrumented UI tests on connected emulator or device. |
| `./gradlew assembleRelease` | Generate release build variant with R8 optimizations. |
| `adb shell dumpsys batterystats` | Profile system battery consumption metrics. |
| `adb shell am dumpheap <package> heap.hprof` | Dump application heap memory file for profiling. |
# Eloquent ORM
## Model Patterns
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'slug',
'content',
'published_at',
'user_id',
];
protected $casts = [
'published_at' => 'datetime',
'metadata' => 'array',
'is_featured' => 'boolean',
];
// Accessor using new Attribute syntax (Laravel 9+)
protected function title(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
// Mutator for computed property
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => str($this->content)->limit(100),
);
}
}
```
## Relationships
```php
// One-to-Many
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();
}
public function oldestPost(): HasOne
{
return $this->hasOne(Post::class)->oldestOfMany();
}
}
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Inverse relationship
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// Many-to-Many with Pivot
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withPivot('expires_at', 'assigned_by')
->withTimestamps()
->using(RoleUser::class); // Custom pivot model
}
}
// Has Many Through
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
// Polymorphic Relations
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Post extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// Many-to-Many Polymorphic
class Tag extends Model
{
public function posts(): MorphToMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function videos(): MorphToMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
}
```
## Query Scopes
```php
class Post extends Model
{
// Local scope
public function scopePublished($query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopePopular($query, int $threshold = 100): void
{
$query->where('views', '>=', $threshold);
}
// Global scope
protected static function booted(): void
{
static::addGlobalScope('active', function ($query) {
$query->where('status', 'active');
});
}
}
// Usage
$posts = Post::published()->popular(500)->get();
// Custom Scope Class
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AncientScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('created_at', '<', now()->subYears(10));
}
}
// Apply in model
protected static function booted(): void
{
static::addGlobalScope(new AncientScope);
}
```
## Custom Casts
```php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Money implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): float
{
return $value / 100; // Store cents, return dollars
}
public function set($model, string $key, $value, array $attributes): int
{
return (int) ($value * 100);
}
}
// In model
protected $casts = [
'price' => Money::class,
];
```
## Query Optimization
```php
// Eager Loading (prevent N+1)
$posts = Post::with(['user', 'comments.user'])->get();
// Lazy Eager Loading
$posts = Post::all();
$posts->load('user');
// Eager Load with Constraints
$users = User::with(['posts' => function ($query) {
$query->where('published', true)->orderBy('created_at', 'desc');
}])->get();
// Count relationships efficiently
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
// Exists checks
$users = User::withExists('posts')->get();
// Chunk for large datasets
Post::chunk(100, function ($posts) {
foreach ($posts as $post) {
// Process post
}
});
// Lazy collection for memory efficiency
Post::lazy()->each(function ($post) {
// Process one at a time
});
```
## Model Events
```php
class Post extends Model
{
protected static function booted(): void
{
static::creating(function ($post) {
$post->slug = str($post->title)->slug();
});
static::updating(function ($post) {
if ($post->isDirty('title')) {
$post->slug = str($post->title)->slug();
}
});
static::deleted(function ($post) {
$post->images()->delete();
});
}
}
// Using Observers
namespace App\Observers;
class PostObserver
{
public function creating(Post $post): void
{
$post->user_id = auth()->id();
}
public function updated(Post $post): void
{
cache()->forget("post.{$post->id}");
}
}
// Register in AppServiceProvider
use App\Models\Post;
use App\Observers\PostObserver;
public function boot(): void
{
Post::observe(PostObserver::class);
}
```
## Advanced Queries
```php
// Subqueries
$users = User::select(['id', 'name'])
->addSelect(['latest_post_title' => Post::select('title')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])->get();
// When conditional queries
$posts = Post::query()
->when($search, fn ($query) => $query->where('title', 'like', "%{$search}%"))
->when($category, fn ($query) => $query->where('category_id', $category))
->get();
// Database transactions
DB::transaction(function () {
$user = User::create([...]);
$user->profile()->create([...]);
$user->assignRole('member');
});
// Pessimistic locking
$user = User::where('id', 1)->lockForUpdate()->first();
// Upserts
User::upsert(
[
['email' => 'john@example.com', 'name' => 'John'],
['email' => 'jane@example.com', 'name' => 'Jane'],
],
['email'], // Unique columns
['name'] // Columns to update
);
```
## Performance Tips
1. **Always eager load relationships** - Avoid N+1 queries
2. **Use chunking for large datasets** - Prevent memory exhaustion
3. **Index foreign keys** - Speed up joins
4. **Use select() to limit columns** - Reduce data transfer
5. **Cache expensive queries** - Use Redis/Memcached
6. **Use database indexing** - Add indexes in migrations
7. **Avoid using model events for heavy operations** - Use queues instead
8. **Use lazy collections** - For processing large datasets
# Livewire Components
## Component Patterns
```php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\WithFileUploads;
use App\Models\Post;
class PostList extends Component
{
use WithPagination, WithFileUploads;
public string $search = '';
public string $sortBy = 'created_at';
public string $sortDirection = 'desc';
public ?int $categoryId = null;
protected $queryString = [
'search' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'categoryId' => ['except' => null],
];
public function updatingSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
return view('livewire.post-list', [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->categoryId, fn($q) => $q->where('category_id', $this->categoryId))
->orderBy($this->sortBy, $this->sortDirection)
->paginate(10),
]);
}
}
```
## Blade Template
```blade
<div>
{{-- Search --}}
<input
type="text"
wire:model.debounce.300ms="search"
placeholder="Search posts..."
class="form-input"
>
{{-- Filter by category --}}
<select wire:model="categoryId">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
{{-- Sortable table --}}
<table>
<thead>
<tr>
<th wire:click="sortBy('title')" style="cursor: pointer">
Title
@if($sortBy === 'title')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
<th wire:click="sortBy('created_at')" style="cursor: pointer">
Date
@if($sortBy === 'created_at')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>{{ $post->created_at->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Pagination --}}
{{ $posts->links() }}
{{-- Loading states --}}
<div wire:loading wire:target="search">
Searching...
</div>
</div>
```
## Form Component
```php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Models\Post;
class PostForm extends Component
{
public ?Post $post = null;
public string $title = '';
public string $content = '';
public array $tags = [];
public $image;
protected function rules(): array
{
return [
'title' => 'required|min:3|max:255',
'content' => 'required|min:10',
'tags' => 'array|max:5',
'tags.*' => 'exists:tags,id',
'image' => 'nullable|image|max:2048',
];
}
public function mount(?Post $post = null): void
{
if ($post) {
$this->post = $post;
$this->title = $post->title;
$this->content = $post->content;
$this->tags = $post->tags->pluck('id')->toArray();
}
}
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
public function save(): void
{
$validated = $this->validate();
if ($this->post) {
$this->post->update($validated);
$message = 'Post updated successfully!';
} else {
$this->post = Post::create($validated);
$message = 'Post created successfully!';
}
if ($this->image) {
$this->post->update([
'image_path' => $this->image->store('posts', 'public'),
]);
}
$this->post->tags()->sync($this->tags);
session()->flash('message', $message);
$this->redirect(route('posts.show', $this->post));
}
public function render()
{
return view('livewire.post-form');
}
}
```
## Form Template
```blade
<form wire:submit.prevent="save">
{{-- Title --}}
<div>
<label for="title">Title</label>
<input
type="text"
wire:model.defer="title"
id="title"
class="@error('title') border-red-500 @enderror"
>
@error('title')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content">Content</label>
<textarea
wire:model.defer="content"
id="content"
class="@error('content') border-red-500 @enderror"
></textarea>
@error('content')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Tags --}}
<div>
<label>Tags</label>
@foreach($availableTags as $tag)
<label>
<input
type="checkbox"
wire:model="tags"
value="{{ $tag->id }}"
>
{{ $tag->name }}
</label>
@endforeach
@error('tags')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- File Upload --}}
<div>
<label>Image</label>
<input type="file" wire:model="image">
@error('image')
<span class="text-red-500">{{ $message }}</span>
@enderror
{{-- Upload progress --}}
<div wire:loading wire:target="image">
Uploading...
</div>
{{-- Preview --}}
@if ($image)
<img src="{{ $image->temporaryUrl() }}" alt="Preview">
@endif
</div>
{{-- Submit --}}
<button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</button>
</form>
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
```
## Real-time Validation
```php
class PostForm extends Component
{
public string $title = '';
protected $rules = [
'title' => 'required|min:3|unique:posts,title',
];
// Real-time validation
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
// Custom validation messages
protected $messages = [
'title.required' => 'The post title is required.',
'title.min' => 'The title must be at least 3 characters.',
'title.unique' => 'This title is already taken.',
];
// Custom attribute names
protected $validationAttributes = [
'title' => 'post title',
];
}
```
## Events
```php
// Emit event
class PostList extends Component
{
public function deletePost($postId): void
{
Post::find($postId)->delete();
$this->emit('postDeleted', $postId);
}
}
// Listen to event
class PostStats extends Component
{
protected $listeners = ['postDeleted' => 'updateStats'];
public function updateStats($postId): void
{
// Update statistics
}
}
// Emit to specific component
$this->emitTo('post-stats', 'refresh');
// Emit to parent/children
$this->emitUp('saved');
$this->emitSelf('refresh');
// Browser events
$this->dispatchBrowserEvent('post-saved', ['id' => $post->id]);
```
## Listen to Browser Events
```blade
<div
x-data
@post-saved.window="alert('Post saved!')"
>
<!-- content -->
</div>
<script>
window.addEventListener('post-saved', event => {
console.log('Post ID:', event.detail.id);
});
</script>
```
## Polling
```blade
{{-- Poll every 2 seconds --}}
<div wire:poll.2s>
Current time: {{ now() }}
</div>
{{-- Poll specific action --}}
<div wire:poll.5s="checkStatus">
Status: {{ $status }}
</div>
{{-- Keep polling until condition --}}
<div wire:poll.keep-alive.2s>
<!-- content -->
</div>
```
## Loading States
```blade
{{-- Basic loading state --}}
<div wire:loading>
Loading...
</div>
{{-- Target specific action --}}
<div wire:loading wire:target="save">
Saving...
</div>
{{-- Hide element while loading --}}
<div wire:loading.remove>
Content (hidden during load)
</div>
{{-- Delay loading indicator --}}
<div wire:loading.delay>
This appears after 200ms
</div>
{{-- Custom delay --}}
<div wire:loading.delay.longest>
This appears after 1s
</div>
{{-- Loading classes --}}
<button
wire:click="save"
wire:loading.class="opacity-50"
wire:loading.class.remove="bg-blue-500"
>
Save
</button>
{{-- Loading attributes --}}
<button
wire:click="save"
wire:loading.attr="disabled"
>
Save
</button>
```
## Traits
```php
// Pagination
use Livewire\WithPagination;
class PostList extends Component
{
use WithPagination;
public function render()
{
return view('livewire.post-list', [
'posts' => Post::paginate(10),
]);
}
}
// File uploads
use Livewire\WithFileUploads;
class UploadPhoto extends Component
{
use WithFileUploads;
public $photo;
public function save(): void
{
$this->validate([
'photo' => 'image|max:1024',
]);
$this->photo->store('photos');
}
}
```
## Authorization
```php
class PostForm extends Component
{
public Post $post;
public function mount(Post $post): void
{
$this->authorize('update', $post);
$this->post = $post;
}
public function save(): void
{
$this->authorize('update', $this->post);
// Save logic
}
}
```
## Performance Tips
1. **Use wire:model.defer** - Batch updates on form submit
2. **Lazy load components** - Use wire:init for heavy operations
3. **Cache computed properties** - Use #[Computed] attribute
4. **Disable polling when hidden** - Use wire:poll.visible
5. **Optimize queries** - Eager load relationships
6. **Use wire:key** - Prevent re-rendering entire lists
7. **Debounce input** - Use wire:model.debounce
8. **Use pagination** - Don't load all records at once
```php
use Livewire\Attributes\Computed;
class PostList extends Component
{
#[Computed]
public function posts()
{
return Post::with('user')->paginate(10);
}
public function render()
{
return view('livewire.post-list');
}
}
```
```blade
{{-- Access computed property --}}
@foreach($this->posts as $post)
<!-- content -->
@endforeach
```
# Queue System
## Job Patterns
```php
namespace App\Jobs;
use App\Models\Post;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public $maxExceptions = 3;
public $backoff = [60, 120, 300]; // Exponential backoff
public function __construct(
public Post $post,
public ?User $user = null,
) {}
public function handle(): void
{
// Process the post
$this->post->update(['processed' => true]);
// Can access injected dependencies
$analytics = app(AnalyticsService::class);
$analytics->trackPostProcessed($this->post);
}
public function failed(\Throwable $exception): void
{
// Handle job failure
\Log::error('Post processing failed', [
'post_id' => $this->post->id,
'error' => $exception->getMessage(),
]);
}
}
```
## Dispatching Jobs
```php
use App\Jobs\ProcessPost;
// Dispatch immediately
ProcessPost::dispatch($post);
// Dispatch to specific queue
ProcessPost::dispatch($post)->onQueue('processing');
// Delayed dispatch
ProcessPost::dispatch($post)->delay(now()->addMinutes(10));
// Dispatch after database commit
ProcessPost::dispatch($post)->afterCommit();
// Dispatch conditionally
ProcessPost::dispatchIf($condition, $post);
ProcessPost::dispatchUnless($condition, $post);
// Synchronous dispatch (no queue)
ProcessPost::dispatchSync($post);
// Dispatch after response
ProcessPost::dispatchAfterResponse($post);
```
## Job Chaining
```php
use App\Jobs\{OptimizeImage, GenerateThumbnail, PublishPost};
// Chain jobs
OptimizeImage::withChain([
new GenerateThumbnail($post),
new PublishPost($post),
])->dispatch($post);
// Catch failures in chain
Bus::chain([
new ProcessPost($post),
new NotifyUser($user),
])->catch(function (\Throwable $e) {
// Handle failure
})->dispatch();
```
## Job Batching
```php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new ProcessPost($post1),
new ProcessPost($post2),
new ProcessPost($post3),
])->then(function (Batch $batch) {
// All jobs completed successfully
})->catch(function (Batch $batch, \Throwable $e) {
// First batch job failure detected
})->finally(function (Batch $batch) {
// The batch has finished executing
})->name('Process Posts')
->allowFailures()
->dispatch();
// Check batch status
$batch = Bus::findBatch($batchId);
if ($batch->finished()) {
// Batch is complete
}
if ($batch->cancelled()) {
// Batch was cancelled
}
// Add jobs to existing batch
$batch->add([
new ProcessPost($post4),
]);
```
## Rate Limiting
```php
use Illuminate\Support\Facades\Redis;
class ProcessPost implements ShouldQueue
{
public function handle(): void
{
Redis::throttle('process-posts')
->block(0)
->allow(10)
->every(60)
->then(function () {
// Lock acquired, process job
}, function () {
// Could not acquire lock, release job back
$this->release(10);
});
}
}
// Or using middleware
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [new RateLimited('process-posts')];
}
```
## Job Middleware
```php
namespace App\Jobs\Middleware;
class RateLimitedByUser
{
public function handle($job, $next): void
{
Redis::throttle("user:{$job->user->id}")
->allow(10)
->every(60)
->then(function () use ($job, $next) {
$next($job);
}, function () use ($job) {
$job->release(10);
});
}
}
// Use in job
use App\Jobs\Middleware\RateLimitedByUser;
public function middleware(): array
{
return [new RateLimitedByUser];
}
// Skip middleware
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping($this->user->id))->expireAfter(180),
];
}
```
## Unique Jobs
```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessPost implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function __construct(
public Post $post,
) {}
public function uniqueId(): string
{
return $this->post->id;
}
}
// Or use unique until processing
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class ProcessPost implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}
```
## Failed Jobs
```php
// Retry failed job
php artisan queue:retry <job-id>
// Retry all failed jobs
php artisan queue:retry all
// Flush failed jobs
php artisan queue:flush
// Prune failed jobs
php artisan queue:prune-failed --hours=48
// Handle in code
use Illuminate\Support\Facades\Queue;
Queue::failing(function (JobFailed $event) {
\Log::error('Job failed', [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
```
## Queue Workers
```bash
# Start worker
php artisan queue:work
# Process specific queue
php artisan queue:work --queue=high,default
# Process one job
php artisan queue:work --once
# Stop worker gracefully
php artisan queue:restart
# Timeout settings
php artisan queue:work --timeout=60
# Memory limit
php artisan queue:work --memory=512
# Max jobs before restart
php artisan queue:work --max-jobs=1000
# Max time before restart
php artisan queue:work --max-time=3600
```
## Horizon Setup
```php
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'maxProcesses' => 10,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 512,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
'supervisor-2' => [
'connection' => 'redis',
'queue' => ['high', 'default'],
'balance' => 'auto',
'maxProcesses' => 5,
'tries' => 3,
],
],
],
];
// Start Horizon
php artisan horizon
// Terminate Horizon
php artisan horizon:terminate
// Pause workers
php artisan horizon:pause
// Continue workers
php artisan horizon:continue
// Check status
php artisan horizon:status
```
## Monitoring
```php
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
// In AppServiceProvider
public function boot(): void
{
Queue::before(function (JobProcessing $event) {
// Called before job is processed
});
Queue::after(function (JobProcessed $event) {
// Called after job is processed
\Log::info('Job processed', [
'job' => $event->job->resolveName(),
'time' => $event->job->processingTime(),
]);
});
Queue::failing(function (JobFailed $event) {
// Called when job fails
\Log::error('Job failed', [
'job' => $event->job->resolveName(),
'exception' => $event->exception,
]);
});
}
```
## Queue Configuration
```php
// config/queue.php
return [
'default' => env('QUEUE_CONNECTION', 'sync'),
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX'),
'queue' => env('SQS_QUEUE'),
'region' => env('AWS_DEFAULT_REGION'),
],
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
```
## Best Practices
1. **Keep jobs small and focused** - Single responsibility
2. **Make jobs idempotent** - Safe to run multiple times
3. **Use type hints** - Better error detection
4. **Set reasonable timeouts** - Prevent hanging jobs
5. **Monitor failed jobs** - Set up alerts
6. **Use batching for bulk operations** - Better performance
7. **Implement proper error handling** - Use failed() method
8. **Use unique jobs** - Prevent duplicate processing
9. **Queue long-running tasks** - Don't block requests
10. **Use Horizon for Redis queues** - Better monitoring
# Routing & API Resources
## Route Patterns
```php
// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
// Resource routes
Route::resource('posts', PostController::class);
// API resource (excludes create/edit)
Route::apiResource('posts', PostController::class);
// Partial resource
Route::resource('posts', PostController::class)->only(['index', 'show']);
Route::resource('posts', PostController::class)->except(['destroy']);
// Nested resources
Route::resource('posts.comments', CommentController::class);
// Route groups
Route::prefix('admin')->middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::resource('users', UserController::class);
});
// Named routes
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
// Route model binding
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
// Multiple bindings
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
return view('posts.show', compact('user', 'post'));
});
```
## API Routes
```php
// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
Route::prefix('v1')->group(function () {
// Public routes
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
});
// Rate limiting
Route::middleware('throttle:60,1')->group(function () {
Route::apiResource('posts', PostController::class);
});
```
## Controllers
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Models\Post;
use Illuminate\Http\Response;
class PostController extends Controller
{
public function index()
{
$posts = Post::with('user')
->published()
->paginate(15);
return new PostCollection($posts);
}
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return new PostResource($post);
}
public function show(Post $post)
{
$post->load(['user', 'comments.user']);
return new PostResource($post);
}
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
return new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->noContent();
}
}
```
## Form Requests
```php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or check user permissions
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'content' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'title.required' => 'Please provide a post title',
'slug.unique' => 'This slug is already taken',
];
}
// Prepare data before validation
protected function prepareForValidation(): void
{
$this->merge([
'slug' => str($this->title)->slug(),
]);
}
}
class UpdatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'slug' => [
'sometimes',
'string',
Rule::unique('posts', 'slug')->ignore($this->post)
],
'content' => ['sometimes', 'string'],
];
}
}
```
## API Resources
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when($request->route()->named('posts.show'), $this->content),
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
// Relationships
'author' => new UserResource($this->whenLoaded('user')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'comments_count' => $this->when($this->comments_count !== null, $this->comments_count),
// Conditional fields
'is_published' => $this->when($request->user()?->isAdmin(), $this->isPublished()),
// Pivot data
'role' => $this->whenPivotLoaded('role_user', function () {
return $this->pivot->role_name;
}),
// Links
'links' => [
'self' => route('api.posts.show', $this->id),
],
];
}
public function with(Request $request): array
{
return [
'meta' => [
'version' => '1.0.0',
],
];
}
}
```
## Resource Collections
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
],
'links' => [
'self' => $request->url(),
],
];
}
}
// Or use anonymous collection
return PostResource::collection($posts);
```
## Middleware
```php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next)
{
if (!$request->user()?->isAdmin()) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
// Register in app/Http/Kernel.php
protected $middlewareAliases = [
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
];
// Use in routes
Route::middleware('admin')->group(function () {
Route::resource('users', UserController::class);
});
```
## Response Helpers
```php
// JSON responses
return response()->json(['data' => $posts], 200);
// Created response
return response()->json($post, 201);
// No content
return response()->noContent();
// Custom headers
return response()->json($data)->header('X-Custom-Header', 'Value');
// Download
return response()->download($pathToFile);
// Stream
return response()->streamDownload(function () {
echo 'CSV content...';
}, 'export.csv');
```
## Route Caching
```bash
# Generate route cache
php artisan route:cache
# Clear route cache
php artisan route:clear
# List all routes
php artisan route:list
# Filter routes
php artisan route:list --name=api
php artisan route:list --path=posts
```
## API Versioning
```php
// routes/api.php
Route::prefix('v1')->name('v1.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
Route::prefix('v2')->name('v2.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});
```
## CORS Configuration
```php
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000'],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
```
# Testing
## Feature Tests
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\{User, Post};
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'Test Post',
'content' => 'This is a test post content.',
]);
$response->assertStatus(201)
->assertJson([
'data' => [
'title' => 'Test Post',
],
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
public function test_guest_cannot_create_post(): void
{
$response = $this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$response->assertStatus(401);
}
public function test_post_requires_valid_data(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'AB', // Too short
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']);
}
public function test_user_can_view_their_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/api/posts');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at'],
],
]);
}
public function test_user_can_update_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
'content' => $post->content,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title',
]);
}
public function test_user_cannot_update_others_post(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
]);
$response->assertStatus(403);
}
}
```
## Unit Tests
```php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostServiceTest extends TestCase
{
use RefreshDatabase;
public function test_generates_unique_slug(): void
{
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post', $slug);
}
public function test_increments_slug_on_duplicate(): void
{
Post::factory()->create(['slug' => 'test-post']);
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post-1', $slug);
}
public function test_post_excerpt_returns_limited_content(): void
{
$post = new Post(['content' => str_repeat('a', 200)]);
$excerpt = $post->excerpt;
$this->assertLessThanOrEqual(100, strlen($excerpt));
}
}
```
## Pest PHP
```php
<?php
use App\Models\{User, Post};
it('allows authenticated users to create posts', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])
->assertStatus(201);
expect(Post::count())->toBe(1);
});
it('prevents guests from creating posts', function () {
$this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])->assertStatus(401);
});
test('post requires title and content', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [])
->assertJsonValidationErrors(['title', 'content']);
});
// Datasets
it('validates title length', function (string $title, bool $shouldPass) {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => $title,
'content' => 'Content',
]);
if ($shouldPass) {
$response->assertStatus(201);
} else {
$response->assertJsonValidationErrors(['title']);
}
})->with([
['AB', false], // Too short
['ABC', true], // Minimum valid
[str_repeat('A', 255), true], // Maximum valid
[str_repeat('A', 256), false], // Too long
]);
// Hooks
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
// Cleanup
});
```
## Factories
```php
namespace Database\Factories;
use App\Models\{User, Category};
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'slug' => fake()->slug(),
'content' => fake()->paragraphs(3, true),
'excerpt' => fake()->text(100),
'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
'user_id' => User::factory(),
'category_id' => Category::factory(),
];
}
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(),
]);
}
public function forUser(User $user): static
{
return $this->state(fn (array $attributes) => [
'user_id' => $user->id,
]);
}
public function configure(): static
{
return $this->afterCreating(function (Post $post) {
$post->tags()->attach(
Tag::factory()->count(3)->create()
);
});
}
}
// Usage
$post = Post::factory()->create();
$unpublished = Post::factory()->unpublished()->create();
$posts = Post::factory()->count(10)->create();
$userPosts = Post::factory()->forUser($user)->count(5)->create();
// With relationships
$post = Post::factory()
->has(Comment::factory()->count(3))
->create();
// For relationship
$posts = Post::factory()
->count(3)
->for($user)
->create();
```
## Mocking
```php
use App\Services\ExternalApiService;
use Illuminate\Support\Facades\Http;
public function test_fetches_data_from_external_api(): void
{
Http::fake([
'api.example.com/*' => Http::response([
'data' => ['id' => 1, 'name' => 'Test'],
], 200),
]);
$service = new ExternalApiService();
$result = $service->fetchData();
$this->assertEquals('Test', $result['name']);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/data' &&
$request->hasHeader('Authorization');
});
}
// Mock events
use Illuminate\Support\Facades\Event;
Event::fake([PostCreated::class]);
// Test code that dispatches events
Event::assertDispatched(PostCreated::class, function ($event) {
return $event->post->id === 1;
});
// Mock queues
use Illuminate\Support\Facades\Queue;
Queue::fake();
// Test code that dispatches jobs
Queue::assertPushed(ProcessPost::class);
Queue::assertPushed(ProcessPost::class, 2);
Queue::assertPushed(ProcessPost::class, function ($job) {
return $job->post->id === 1;
});
// Mock notifications
use Illuminate\Support\Facades\Notification;
Notification::fake();
// Test code that sends notifications
Notification::assertSentTo($user, PostPublished::class);
// Mock storage
use Illuminate\Support\Facades\Storage;
Storage::fake('public');
// Test file upload
Storage::disk('public')->assertExists('file.jpg');
Storage::disk('public')->assertMissing('missing.jpg');
```
## Database Testing
```php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class PostTest extends TestCase
{
use RefreshDatabase; // Migrate database before each test
// Or use transactions
use DatabaseTransactions; // Rollback after each test
public function test_database_assertions(): void
{
$post = Post::factory()->create([
'title' => 'Test Post',
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
]);
$post->delete();
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
}
public function test_model_exists(): void
{
$post = Post::factory()->create();
$this->assertModelExists($post);
$post->delete();
$this->assertModelMissing($post);
}
}
```
## API Testing
```php
public function test_api_returns_paginated_posts(): void
{
Post::factory()->count(30)->create();
$response = $this->get('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content'],
],
'meta' => ['total', 'current_page', 'last_page'],
'links' => ['first', 'last', 'prev', 'next'],
])
->assertJsonCount(15, 'data'); // Default per page
}
public function test_api_filters_posts_by_category(): void
{
$category = Category::factory()->create();
Post::factory()->count(5)->create(['category_id' => $category->id]);
Post::factory()->count(5)->create();
$response = $this->get("/api/posts?category={$category->id}");
$response->assertJsonCount(5, 'data')
->assertJson([
'data' => [
['category_id' => $category->id],
],
]);
}
```
## Authentication Testing
```php
use Laravel\Sanctum\Sanctum;
public function test_authenticated_user_can_access_endpoint(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$response = $this->get('/api/user');
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function test_user_with_wrong_ability_cannot_access(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['view-posts']);
$response = $this->post('/api/posts', [
'title' => 'Test',
'content' => 'Content',
]);
$response->assertStatus(403);
}
```
## Running Tests
```bash
# Run all tests
php artisan test
# Run specific test
php artisan test --filter=test_user_can_create_post
# Run test file
php artisan test tests/Feature/PostTest.php
# Parallel testing
php artisan test --parallel
# With coverage
php artisan test --coverage
# Coverage minimum
php artisan test --coverage --min=80
# Stop on failure
php artisan test --stop-on-failure
# Pest specific
./vendor/bin/pest
./vendor/bin/pest --filter=PostTest
./vendor/bin/pest --coverage
```
## Best Practices
1. **Use RefreshDatabase** - Clean database for each test
2. **Use factories** - Don't manually create test data
3. **Test one thing** - Each test should verify one behavior
4. **Use descriptive names** - test_user_can_create_post
5. **AAA pattern** - Arrange, Act, Assert
6. **Mock external services** - Don't make real API calls
7. **Fake queues and events** - Test async code synchronously
8. **Test edge cases** - Invalid data, permissions, etc.
9. **Achieve >85% coverage** - Test critical paths
10. **Run tests in CI/CD** - Automate test execution
---
name: laravel-specialist
description: Build and configure Laravel 10+ applications, including creating Eloquent models and relationships, implementing Sanctum authentication, configuring Horizon queues, designing RESTful APIs with API resources, and building reactive interfaces with Livewire. Use when creating Laravel models, setting up queue workers, implementing Sanctum auth flows, building Livewire components, optimising Eloquent queries, or writing Pest/PHPUnit tests for Laravel features.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: backend
triggers: Laravel, Eloquent, PHP framework, Laravel API, Artisan, Blade templates, Laravel queues, Livewire, Laravel testing, Sanctum, Horizon
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, test-master, devops-engineer, security-reviewer
---
# Laravel Specialist
Senior Laravel specialist with deep expertise in Laravel 10+, Eloquent ORM, and modern PHP 8.2+ development.
## Core Workflow
1. **Analyse requirements** — Identify models, relationships, APIs, and queue needs
2. **Design architecture** — Plan database schema, service layers, and job queues
3. **Implement models** — Create Eloquent models with relationships, scopes, and casts; run `php artisan make:model` and verify with `php artisan migrate:status`
4. **Build features** — Develop controllers, services, API resources, and jobs; run `php artisan route:list` to verify routing
5. **Test thoroughly** — Write feature and unit tests; run `php artisan test` before considering any step complete (target >85% coverage)
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Eloquent ORM | `references/eloquent.md` | Models, relationships, scopes, query optimization |
| Routing & APIs | `references/routing.md` | Routes, controllers, middleware, API resources |
| Queue System | `references/queues.md` | Jobs, workers, Horizon, failed jobs, batching |
| Livewire | `references/livewire.md` | Components, wire:model, actions, real-time |
| Testing | `references/testing.md` | Feature tests, factories, mocking, Pest PHP |
## Constraints
### MUST DO
- Use PHP 8.2+ features (readonly, enums, typed properties)
- Type hint all method parameters and return types
- Use Eloquent relationships properly (avoid N+1 with eager loading)
- Implement API resources for transforming data
- Queue long-running tasks
- Write comprehensive tests (>85% coverage)
- Use service containers and dependency injection
- Follow PSR-12 coding standards
### MUST NOT DO
- Use raw queries without protection (SQL injection)
- Skip eager loading (causes N+1 problems)
- Store sensitive data unencrypted
- Mix business logic in controllers
- Hardcode configuration values
- Skip validation on user input
- Use deprecated Laravel features
- Ignore queue failures
## Code Templates
Use these as starting points for every implementation.
### Eloquent Model
```php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['title', 'body', 'status', 'user_id'];
protected $casts = [
'status' => PostStatus::class, // backed enum
'published_at' => 'immutable_datetime',
];
// Relationships — always eager-load via ::with() at call site
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Local scope
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PostStatus::Published);
}
}
```
### Migration
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
```
### API Resource
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'status' => $this->status->value,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
];
}
}
```
### Queued Job
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class PublishPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private readonly Post $post,
) {}
public function handle(): void
{
$this->post->update([
'status' => PostStatus::Published,
'published_at' => now(),
]);
}
public function failed(\Throwable $e): void
{
// Log or notify — never silently swallow failures
logger()->error('PublishPost failed', ['post' => $this->post->id, 'error' => $e->getMessage()]);
}
}
```
### Feature Test (Pest)
```php
<?php
use App\Models\Post;
use App\Models\User;
it('returns a published post for authenticated users', function (): void {
$user = User::factory()->create();
$post = Post::factory()->published()->for($user, 'author')->create();
$response = $this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$response->assertOk()
->assertJsonPath('data.status', 'published')
->assertJsonPath('data.author.id', $user->id);
});
it('queues a publish job when a draft is submitted', function (): void {
Queue::fake();
$user = User::factory()->create();
$post = Post::factory()->draft()->for($user, 'author')->create();
$this->actingAs($user)
->postJson("/api/posts/{$post->id}/publish")
->assertAccepted();
Queue::assertPushed(PublishPost::class, fn ($job) => $job->post->is($post));
});
```
## Validation Checkpoints
Run these at each workflow stage to confirm correctness before proceeding:
| Stage | Command | Expected Result |
|-------|---------|-----------------|
| After migration | `php artisan migrate:status` | All migrations show `Ran` |
| After routing | `php artisan route:list --path=api` | New routes appear with correct verbs |
| After job dispatch | `php artisan queue:work --once` | Job processes without exception |
| After implementation | `php artisan test --coverage` | >85% coverage, 0 failures |
| Before PR | `./vendor/bin/pint --test` | PSR-12 linting passes |
## Knowledge Reference
Laravel 10+, Eloquent ORM, PHP 8.2+, API resources, Sanctum/Passport, queues, Horizon, Livewire, Inertia, Octane, Pest/PHPUnit, Redis, broadcasting, events/listeners, notifications, task scheduling
[Documentation](https://jeffallan.github.io/claude-skills/skills/backend/laravel-specialist/)
# Async PHP Patterns
## Swoole HTTP Server
```php
<?php
declare(strict_types=1);
use Swoole\HTTP\Server;
use Swoole\HTTP\Request;
use Swoole\HTTP\Response;
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4,
'max_request' => 10000,
'task_worker_num' => 2,
'enable_coroutine' => true,
]);
$server->on('start', function (Server $server) {
echo "Swoole HTTP server started at http://0.0.0.0:9501\n";
});
$server->on('request', function (Request $request, Response $response) {
$response->header('Content-Type', 'application/json');
match ($request->server['request_uri']) {
'/api/users' => handleUsers($request, $response),
'/api/health' => $response->end(json_encode(['status' => 'healthy'])),
default => $response->status(404)->end(json_encode(['error' => 'Not found'])),
};
});
function handleUsers(Request $request, Response $response): void
{
// Coroutine for concurrent DB queries
go(function () use ($response) {
$users = queryDatabase('SELECT * FROM users LIMIT 10');
$response->end(json_encode(['data' => $users]));
});
}
$server->start();
```
## Swoole Coroutines
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Http\Client;
// Concurrent HTTP requests
Coroutine\run(function () {
$results = [];
// Create multiple coroutines
$wg = new Coroutine\WaitGroup();
$urls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments',
];
foreach ($urls as $url) {
$wg->add();
go(function () use ($url, &$results, $wg) {
$client = new Client(parse_url($url, PHP_URL_HOST), 443, true);
$client->set(['timeout' => 5]);
$client->get(parse_url($url, PHP_URL_PATH));
$results[$url] = [
'status' => $client->statusCode,
'body' => $client->body,
];
$client->close();
$wg->done();
});
}
$wg->wait();
print_r($results);
});
```
## Swoole Async MySQL
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\MySQL;
Coroutine\run(function () {
$mysql = new MySQL();
$connected = $mysql->connect([
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'root',
'password' => 'password',
'database' => 'test',
]);
if (!$connected) {
throw new \RuntimeException($mysql->connect_error);
}
// Async query
$result = $mysql->query('SELECT * FROM users WHERE active = 1');
foreach ($result as $row) {
echo "User: {$row['name']}\n";
}
// Prepared statements
$stmt = $mysql->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$user = $stmt->fetchAll();
$mysql->close();
});
```
## Swoole Channel (Communication)
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
Coroutine\run(function () {
$channel = new Channel(10); // Buffer size: 10
// Producer
go(function () use ($channel) {
for ($i = 1; $i <= 5; $i++) {
$channel->push("Task {$i}");
echo "Produced: Task {$i}\n";
Coroutine::sleep(0.5);
}
$channel->close();
});
// Consumer
go(function () use ($channel) {
while (true) {
$task = $channel->pop();
if ($task === false && $channel->errCode === SWOOLE_CHANNEL_CLOSED) {
break;
}
echo "Consumed: {$task}\n";
Coroutine::sleep(1);
}
});
});
```
## ReactPHP Event Loop
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\EventLoop\Loop;
use React\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;
// HTTP Server
$server = new React\Http\HttpServer(function (ServerRequestInterface $request) {
return new Response(
200,
['Content-Type' => 'application/json'],
json_encode([
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'timestamp' => time(),
])
);
});
$socket = new React\Socket\SocketServer('0.0.0.0:8080');
$server->listen($socket);
echo "Server running at http://0.0.0.0:8080\n";
// Periodic timer
Loop::addPeriodicTimer(5.0, function () {
echo "Heartbeat: " . date('H:i:s') . "\n";
});
// One-time timer
Loop::addTimer(10.0, function () {
echo "This runs once after 10 seconds\n";
});
```
## ReactPHP Async MySQL
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\MySQL\Factory;
use React\MySQL\QueryResult;
$factory = new Factory();
$connection = $factory->createLazyConnection('root:password@localhost/database');
$connection->query('SELECT * FROM users WHERE active = 1')
->then(
function (QueryResult $result) {
echo "Found " . count($result->resultRows) . " users\n";
foreach ($result->resultRows as $row) {
echo "User: {$row['name']}\n";
}
},
function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
}
);
// Prepared statements
$connection->query('SELECT * FROM users WHERE id = ?', [42])
->then(function (QueryResult $result) {
$user = $result->resultRows[0] ?? null;
var_dump($user);
});
```
## ReactPHP Promises
```php
<?php
declare(strict_types=1);
use React\Promise\Promise;
use React\Promise\Deferred;
use function React\Promise\all;
// Creating promises
function fetchUser(int $id): Promise
{
$deferred = new Deferred();
// Simulate async operation
Loop::addTimer(1.0, function () use ($deferred, $id) {
$deferred->resolve([
'id' => $id,
'name' => "User {$id}",
]);
});
return $deferred->promise();
}
// Using promises
fetchUser(42)
->then(function ($user) {
echo "Got user: {$user['name']}\n";
return fetchUserPosts($user['id']);
})
->then(function ($posts) {
echo "Got " . count($posts) . " posts\n";
})
->catch(function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
});
// Parallel promises
all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
])->then(function ($users) {
echo "Fetched " . count($users) . " users\n";
});
```
## PHP Fibers (Native PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Simple async function using fibers
function async(callable $callback): Fiber
{
return new Fiber($callback);
}
function await(Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
if ($fiber->isTerminated()) {
return $fiber->getReturn();
}
return $fiber->resume();
}
// Simulate async I/O
function fetchData(string $url): Fiber
{
return async(function () use ($url) {
echo "Fetching: {$url}\n";
Fiber::suspend('pending');
// Simulate network delay
sleep(1);
return "Data from {$url}";
});
}
// Usage
$fiber1 = fetchData('https://api.example.com/users');
$fiber2 = fetchData('https://api.example.com/posts');
await($fiber1);
await($fiber2);
$result1 = await($fiber1);
$result2 = await($fiber2);
echo "{$result1}\n";
echo "{$result2}\n";
```
## Amphp Framework
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Amp\Http\Server\HttpServer;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
use Amp\Http\Server\Router;
use Amp\Socket\Server as SocketServer;
use function Amp\async;
use function Amp\Future\await;
// HTTP Server with Amphp
$router = new Router();
$router->addRoute('GET', '/api/users', function (Request $request): Response {
// Concurrent database queries
$users = await([
async(fn() => queryUsers()),
async(fn() => queryUserStats()),
]);
return new Response(
status: 200,
headers: ['content-type' => 'application/json'],
body: json_encode(['users' => $users[0], 'stats' => $users[1]]),
);
});
$server = new HttpServer(
servers: [SocketServer::listen('0.0.0.0:8080')],
requestHandler: $router,
);
$server->start();
```
## Quick Reference
| Technology | Use Case | Performance |
|------------|----------|-------------|
| Swoole | High-performance servers, WebSockets | Very High |
| ReactPHP | Event-driven apps, real-time | High |
| Amphp | Modern async framework | High |
| Fibers | Native async (PHP 8.1+) | Medium |
| Generators | Simple async patterns | Medium |
| Feature | Swoole | ReactPHP | Amphp |
|---------|--------|----------|-------|
| Coroutines | Yes | No (Promises) | Yes (Fibers) |
| HTTP Server | Built-in | Via package | Via package |
| WebSockets | Built-in | Via package | Via package |
| Extension | Required | Not required | Not required |
| Learning Curve | Medium | Low | Medium |
# Laravel Patterns
## Service Layer Pattern
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTOs\CreateUserData;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailService $emailService,
) {}
public function createUser(CreateUserData $data): User
{
$user = $this->userRepository->create([
'name' => $data->name,
'email' => $data->email,
'password' => Hash::make($data->password),
]);
$this->emailService->sendWelcomeEmail($user);
return $user;
}
public function suspendUser(int $userId, string $reason): void
{
$user = $this->userRepository->findOrFail($userId);
$this->userRepository->update($user->id, [
'status' => UserStatus::SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
$this->emailService->sendSuspensionNotice($user, $reason);
}
}
```
## Repository Pattern
```php
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
interface UserRepositoryInterface
{
public function findOrFail(int $id): User;
public function findByEmail(string $email): ?User;
public function create(array $data): User;
public function update(int $id, array $data): User;
public function delete(int $id): void;
public function getActive(): Collection;
}
final class UserRepository implements UserRepositoryInterface
{
public function findOrFail(int $id): User
{
return User::findOrFail($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function create(array $data): User
{
return User::create($data);
}
public function update(int $id, array $data): User
{
$user = $this->findOrFail($id);
$user->update($data);
return $user->fresh();
}
public function delete(int $id): void
{
$this->findOrFail($id)->delete();
}
public function getActive(): Collection
{
return User::where('status', UserStatus::ACTIVE)
->orderBy('created_at', 'desc')
->get();
}
}
```
## Form Requests with Enums
```php
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
final class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', User::class) ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', new Enum(UserRole::class)],
'settings' => ['sometimes', 'array'],
'settings.theme' => ['string', Rule::in(['light', 'dark'])],
];
}
public function toDto(): CreateUserData
{
return new CreateUserData(
name: $this->validated('name'),
email: $this->validated('email'),
password: $this->validated('password'),
role: UserRole::from($this->validated('role')),
);
}
}
```
## API Resources
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\User
*/
final class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status->value,
'role' => $this->role->value,
'created_at' => $this->created_at->toIso8601String(),
// Conditional relationships
'posts' => PostResource::collection($this->whenLoaded('posts')),
'profile' => new ProfileResource($this->whenLoaded('profile')),
// Conditional attributes
'is_admin' => $this->when($this->role === UserRole::ADMIN, true),
// Pivot data
'team_role' => $this->whenPivotLoaded('team_user', fn() =>
$this->pivot->role
),
];
}
}
final class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),
],
];
}
}
```
## Controllers with DTOs
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class UserController extends Controller
{
public function __construct(
private readonly UserService $userService,
) {}
public function index(): AnonymousResourceCollection
{
$users = User::with('profile')
->where('status', UserStatus::ACTIVE)
->paginate(20);
return UserResource::collection($users);
}
public function store(CreateUserRequest $request): JsonResponse
{
$user = $this->userService->createUser($request->toDto());
return (new UserResource($user))
->response()
->setStatusCode(201);
}
public function show(User $user): UserResource
{
$user->load(['posts', 'profile']);
return new UserResource($user);
}
public function destroy(User $user): JsonResponse
{
$this->authorize('delete', $user);
$this->userService->deleteUser($user->id);
return response()->json(null, 204);
}
}
```
## Jobs & Queues
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(
private readonly int $userId,
) {}
public function handle(EmailService $emailService): void
{
$user = User::findOrFail($this->userId);
$emailService->sendWelcomeEmail($user);
}
public function failed(\Throwable $exception): void
{
\Log::error('Failed to send welcome email', [
'user_id' => $this->userId,
'error' => $exception->getMessage(),
]);
}
}
// Dispatching jobs
SendWelcomeEmail::dispatch($user->id);
SendWelcomeEmail::dispatch($user->id)->delay(now()->addMinutes(5));
SendWelcomeEmail::dispatch($user->id)->onQueue('emails');
```
## Event Listeners
```php
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final readonly class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
) {}
}
namespace App\Listeners;
use App\Events\UserRegistered;
use App\Jobs\SendWelcomeEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
final class SendWelcomeNotification implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
SendWelcomeEmail::dispatch($event->user->id);
}
}
// In EventServiceProvider
protected $listen = [
UserRegistered::class => [
SendWelcomeNotification::class,
UpdateUserStatistics::class,
],
];
```
## Quick Reference
| Pattern | Purpose | File Location |
|---------|---------|---------------|
| Service | Business logic | `app/Services/` |
| Repository | Data access | `app/Repositories/` |
| Form Request | Validation | `app/Http/Requests/` |
| Resource | API responses | `app/Http/Resources/` |
| Job | Async tasks | `app/Jobs/` |
| Event | Domain events | `app/Events/` |
| DTO | Data transfer | `app/DTOs/` |
| Policy | Authorization | `app/Policies/` |
# Modern PHP 8.3+ Features
## Strict Types & Type Declarations
```php
<?php
declare(strict_types=1);
namespace App\Domain\User;
final readonly class User
{
public function __construct(
public int $id,
public string $email,
public UserStatus $status,
public \DateTimeImmutable $createdAt,
) {}
}
function calculateTotal(int $price, float $taxRate): float
{
return $price * (1 + $taxRate);
}
// Union types
function processId(int|string $id): string
{
return is_int($id) ? (string)$id : $id;
}
// Intersection types
interface Timestamped {}
interface Authenticatable {}
function handleUser(Timestamped&Authenticatable $user): void {}
```
## Enums with Methods
```php
<?php
declare(strict_types=1);
enum UserStatus: string
{
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case DELETED = 'deleted';
public function label(): string
{
return match($this) {
self::ACTIVE => 'Active User',
self::SUSPENDED => 'Suspended',
self::DELETED => 'Deleted User',
};
}
public function canLogin(): bool
{
return $this === self::ACTIVE;
}
public static function fromString(string $value): self
{
return self::from(strtolower($value));
}
}
enum HttpStatus: int
{
case OK = 200;
case CREATED = 201;
case BAD_REQUEST = 400;
case UNAUTHORIZED = 401;
case NOT_FOUND = 404;
case SERVER_ERROR = 500;
public function isSuccess(): bool
{
return $this->value >= 200 && $this->value < 300;
}
}
```
## Readonly Properties & Classes
```php
<?php
declare(strict_types=1);
// Readonly class (PHP 8.2+)
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
// Individual readonly properties
class Configuration
{
public function __construct(
public readonly string $apiKey,
public readonly string $apiSecret,
private string $cache = '',
) {}
}
```
## Attributes (Metadata)
```php
<?php
declare(strict_types=1);
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public array $middleware = [],
) {}
}
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final readonly class Validate
{
public function __construct(
public ?string $rule = null,
public ?int $min = null,
public ?int $max = null,
) {}
}
// Using attributes
#[Route('/api/users', method: 'POST', middleware: ['auth'])]
final class CreateUserController
{
public function __invoke(CreateUserRequest $request): JsonResponse
{
// ...
}
}
class UserDto
{
#[Validate(rule: 'email')]
public string $email;
#[Validate(min: 8, max: 100)]
public string $password;
}
```
## First-Class Callables
```php
<?php
declare(strict_types=1);
class UserService
{
public function findById(int $id): ?User {}
public function create(array $data): User {}
}
$service = new UserService();
// PHP 8.1+ first-class callable syntax
$finder = $service->findById(...);
$user = $finder(42);
// Array operations
$numbers = [1, 2, 3, 4, 5];
$doubled = array_map(fn($n) => $n * 2, $numbers);
// Named arguments with callable
$result = array_filter(
array: $numbers,
callback: fn($n) => $n % 2 === 0,
);
```
## Match Expressions
```php
<?php
declare(strict_types=1);
function getStatusColor(UserStatus $status): string
{
return match ($status) {
UserStatus::ACTIVE => 'green',
UserStatus::SUSPENDED => 'yellow',
UserStatus::DELETED => 'red',
};
}
function calculateShipping(int $weight, string $zone): float
{
return match (true) {
$weight < 1000 => 5.00,
$weight < 5000 && $zone === 'local' => 10.00,
$weight < 5000 => 15.00,
default => 25.00,
};
}
// Match with multiple conditions
function getHttpMessage(int $code): string
{
return match ($code) {
200, 201, 204 => 'Success',
400, 422 => 'Client Error',
401, 403 => 'Unauthorized',
500, 502, 503 => 'Server Error',
default => 'Unknown',
};
}
```
## Fibers (PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Basic fiber example
$fiber = new \Fiber(function (): void {
$value = \Fiber::suspend('fiber started');
echo "Received: {$value}\n";
\Fiber::suspend('second suspend');
echo "Fiber completed\n";
});
$result1 = $fiber->start();
echo "First result: {$result1}\n";
$result2 = $fiber->resume('data from main');
echo "Second result: {$result2}\n";
$fiber->resume('final data');
// Async-style with fibers
function async(callable $callback): \Fiber
{
return new \Fiber($callback);
}
function await(\Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
return $fiber->resume();
}
```
## Never Type
```php
<?php
declare(strict_types=1);
function redirect(string $url): never
{
header("Location: {$url}");
exit;
}
function abort(int $code, string $message): never
{
http_response_code($code);
echo json_encode(['error' => $message]);
exit;
}
class NotFoundException extends \Exception
{
public static function throw(string $resource): never
{
throw new self("Resource not found: {$resource}");
}
}
```
## Quick Reference
| Feature | PHP Version | Usage |
|---------|-------------|-------|
| Readonly properties | 8.1+ | `public readonly string $name` |
| Readonly classes | 8.2+ | `readonly class User {}` |
| Enums | 8.1+ | `enum Status: string {}` |
| First-class callables | 8.1+ | `$fn = $obj->method(...)` |
| Never type | 8.1+ | `function exit(): never` |
| Fibers | 8.1+ | `new \Fiber(fn() => ...)` |
| Pure intersection types | 8.1+ | `A&B $param` |
| DNF types | 8.2+ | `(A&B)\|C $param` |
| Constants in traits | 8.2+ | `trait T { const X = 1; }` |
# Symfony Patterns
## Dependency Injection
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepositoryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public function createUser(string $email, string $password): User
{
$user = new User($email, password_hash($password, PASSWORD_ARGON2ID));
$this->userRepository->save($user);
$this->logger->info('User created', ['email' => $email]);
return $user;
}
}
```
## Service Configuration (services.yaml)
```yaml
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $projectDir: '%kernel.project_dir%'
bool $isDebug: '%kernel.debug%'
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Interface binding
App\Repository\UserRepositoryInterface:
class: App\Repository\DoctrineUserRepository
# Service with specific configuration
App\Service\PaymentService:
arguments:
$apiKey: '%env(PAYMENT_API_KEY)%'
$timeout: 30
# Tagged services
App\EventSubscriber\:
resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber']
```
## Controllers with Attributes
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\DTO\CreateUserRequest;
use App\Entity\User;
use App\Service\UserService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/users', name: 'api_users_')]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserService $userService,
) {}
#[Route('', name: 'list', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function list(): JsonResponse
{
$users = $this->userService->getAllUsers();
return $this->json($users, Response::HTTP_OK, [], [
'groups' => ['user:read'],
]);
}
#[Route('', name: 'create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(
#[MapRequestPayload] CreateUserRequest $request
): JsonResponse {
$user = $this->userService->createUser(
$request->email,
$request->password
);
return $this->json($user, Response::HTTP_CREATED, [], [
'groups' => ['user:read'],
]);
}
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(User $user): JsonResponse
{
$this->denyAccessUnlessGranted('view', $user);
return $this->json($user, context: ['groups' => ['user:detail']]);
}
}
```
## DTOs with Validation
```php
<?php
declare(strict_types=1);
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateUserRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 100)]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)]
public string $password,
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name,
#[Assert\Choice(choices: ['admin', 'user', 'moderator'])]
public string $role = 'user',
) {}
}
final readonly class UpdateUserRequest
{
public function __construct(
#[Assert\Email]
public ?string $email = null,
#[Assert\Length(min: 2, max: 100)]
public ?string $name = null,
#[Assert\Type('bool')]
public ?bool $isActive = null,
) {}
}
```
## Event Subscribers
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\UserRegisteredEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserSubscriber implements EventSubscriberInterface
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
UserRegisteredEvent::class => [
['sendWelcomeEmail', 10],
['logRegistration', 5],
],
];
}
public function sendWelcomeEmail(UserRegisteredEvent $event): void
{
$user = $event->getUser();
// Send email logic
$this->logger->info('Welcome email sent', ['user_id' => $user->getId()]);
}
public function logRegistration(UserRegisteredEvent $event): void
{
$this->logger->info('User registered', [
'user_id' => $event->getUser()->getId(),
'email' => $event->getUser()->getEmail(),
]);
}
}
```
## Custom Events
```php
<?php
declare(strict_types=1);
namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class UserRegisteredEvent extends Event
{
public function __construct(
private readonly User $user,
private readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
public function getUser(): User
{
return $this->user;
}
public function getOccurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
}
// Dispatching events
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final readonly class UserService
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
) {}
public function registerUser(string $email, string $password): User
{
$user = new User($email, $password);
// ... save user
$this->eventDispatcher->dispatch(new UserRegisteredEvent($user));
return $user;
}
}
```
## Console Commands
```php
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:create',
description: 'Create a new user',
)]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserService $userService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'User email')
->addArgument('password', InputArgument::REQUIRED, 'User password')
->addOption('admin', 'a', InputOption::VALUE_NONE, 'Make user admin');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$password = $input->getArgument('password');
$isAdmin = $input->getOption('admin');
$user = $this->userService->createUser($email, $password, $isAdmin);
$io->success(sprintf('User created with ID: %d', $user->getId()));
return Command::SUCCESS;
}
}
```
## Voters (Authorization)
```php
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class PostVoter extends Voter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Post;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Post $post */
$post = $subject;
return match ($attribute) {
self::VIEW => $this->canView($post, $user),
self::EDIT => $this->canEdit($post, $user),
self::DELETE => $this->canDelete($post, $user),
default => false,
};
}
private function canView(Post $post, User $user): bool
{
return $post->isPublished() || $this->isOwner($post, $user);
}
private function canEdit(Post $post, User $user): bool
{
return $this->isOwner($post, $user);
}
private function canDelete(Post $post, User $user): bool
{
return $this->isOwner($post, $user) || $user->hasRole('ROLE_ADMIN');
}
private function isOwner(Post $post, User $user): bool
{
return $post->getAuthor()->getId() === $user->getId();
}
}
```
## Message Handler (Messenger)
```php
<?php
declare(strict_types=1);
namespace App\Message;
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
) {}
}
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendWelcomeEmailHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return;
}
// Send email logic
}
}
// Dispatching messages
use Symfony\Component\Messenger\MessageBusInterface;
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId()));
```
## Quick Reference
| Component | Purpose | File Location |
|-----------|---------|---------------|
| Controller | HTTP handlers | `src/Controller/` |
| Service | Business logic | `src/Service/` |
| Repository | Data access | `src/Repository/` |
| Event | Domain events | `src/Event/` |
| EventSubscriber | Event handlers | `src/EventSubscriber/` |
| Command | CLI commands | `src/Command/` |
| Voter | Authorization | `src/Security/Voter/` |
| Message | Async messages | `src/Message/` |
| MessageHandler | Message handlers | `src/MessageHandler/` |
| DTO | Data transfer | `src/DTO/` |
# Testing & Quality Assurance
## PHPUnit with Strict Types
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use App\Service\EmailService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $userRepository;
private EmailService&MockObject $emailService;
private UserService $userService;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->emailService = $this->createMock(EmailService::class);
$this->userService = new UserService(
$this->userRepository,
$this->emailService
);
}
public function testCreateUserSuccessfully(): void
{
$email = 'test@example.com';
$password = 'SecurePass123!';
$this->userRepository
->expects($this->once())
->method('findByEmail')
->with($email)
->willReturn(null);
$this->userRepository
->expects($this->once())
->method('create')
->willReturn($this->createUser($email));
$this->emailService
->expects($this->once())
->method('sendWelcomeEmail');
$user = $this->userService->createUser($email, $password);
$this->assertSame($email, $user->email);
}
public function testCreateUserThrowsExceptionWhenEmailExists(): void
{
$this->expectException(\DomainException::class);
$this->expectExceptionMessage('Email already exists');
$this->userRepository
->method('findByEmail')
->willReturn($this->createUser('test@example.com'));
$this->userService->createUser('test@example.com', 'password');
}
private function createUser(string $email): User
{
return new User(
id: 1,
email: $email,
password: password_hash('password', PASSWORD_ARGON2ID),
);
}
}
```
## Data Providers
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Validator;
use App\Validator\EmailValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EmailValidatorTest extends TestCase
{
#[Test]
#[DataProvider('validEmailProvider')]
public function itValidatesCorrectEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid($email));
}
#[Test]
#[DataProvider('invalidEmailProvider')]
public function itRejectsInvalidEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid($email));
}
public static function validEmailProvider(): array
{
return [
['user@example.com'],
['john.doe@company.co.uk'],
['test+filter@domain.org'],
];
}
public static function invalidEmailProvider(): array
{
return [
['invalid'],
['@example.com'],
['user@'],
['user space@example.com'],
];
}
}
```
## Laravel Feature Tests
```php
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
final class UserControllerTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function testUserCanViewTheirProfile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/api/users/me');
$response->assertOk()
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function testUserCanUpdateTheirProfile(): void
{
$user = User::factory()->create();
$newName = $this->faker->name();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'name' => $newName,
]);
$response->assertOk();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => $newName,
]);
}
public function testUnauthorizedUserCannotAccessProfile(): void
{
$response = $this->getJson('/api/users/me');
$response->assertUnauthorized();
}
public function testValidationFailsWithInvalidData(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'email' => 'not-an-email',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
}
}
```
## Pest Testing (Modern Alternative)
```php
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\UserService;
beforeEach(function () {
$this->userService = app(UserService::class);
});
it('creates a user successfully', function () {
$user = $this->userService->createUser(
email: 'test@example.com',
password: 'SecurePass123!'
);
expect($user)
->toBeInstanceOf(User::class)
->email->toBe('test@example.com');
});
it('validates email format', function (string $email, bool $valid) {
$validator = new EmailValidator();
expect($validator->isValid($email))->toBe($valid);
})->with([
['test@example.com', true],
['invalid', false],
['@example.com', false],
]);
test('authenticated user can view profile', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/api/users/me')
->assertOk()
->assertJson(['data' => ['email' => $user->email]]);
});
test('guest cannot access protected routes', function () {
$this->getJson('/api/users/me')
->assertUnauthorized();
});
```
## PHPStan Configuration
```neon
# phpstan.neon
parameters:
level: 9
paths:
- src
- tests
excludePaths:
- src/bootstrap.php
- vendor
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
tmpDir: var/cache/phpstan
ignoreErrors:
# Ignore specific Laravel magic
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
type_coverage:
return_type: 100
param_type: 100
property_type: 100
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
```
## PHPStan Annotations
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<User>
*/
final class UserRepository extends EntityRepository
{
/**
* @return User[]
*/
public function findActive(): array
{
return $this->createQueryBuilder('u')
->where('u.status = :status')
->setParameter('status', 'active')
->getQuery()
->getResult();
}
/**
* @param int[] $ids
* @return User[]
*/
public function findByIds(array $ids): array
{
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
}
/**
* @template T
*/
final readonly class Result
{
/**
* @param T $data
*/
public function __construct(
public mixed $data,
public bool $success,
) {}
/**
* @return T
*/
public function getData(): mixed
{
return $this->data;
}
}
```
## Mockery (Advanced Mocking)
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepository;
use App\Service\NotificationService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
final class NotificationServiceTest extends TestCase
{
use MockeryPHPUnitIntegration;
public function testSendsNotificationToActiveUsers(): void
{
$repository = Mockery::mock(UserRepository::class);
$repository->shouldReceive('findActive')
->once()
->andReturn([
$this->createUser('user1@example.com'),
$this->createUser('user2@example.com'),
]);
$service = new NotificationService($repository);
$result = $service->notifyActiveUsers('Important message');
$this->assertSame(2, $result->count());
}
public function testHandlesEmailServiceFailure(): void
{
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldReceive('send')
->once()
->andThrow(new \RuntimeException('Email service down'));
$service = new NotificationService($emailService);
$this->expectException(\RuntimeException::class);
$service->sendNotification('test@example.com', 'Hello');
}
private function createUser(string $email): User
{
return new User(id: 1, email: $email, password: 'hashed');
}
}
```
## Code Coverage
```xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/bootstrap</directory>
<file>src/Kernel.php</file>
</exclude>
<report>
<html outputDirectory="coverage/html"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
```
## Quick Reference
| Tool | Purpose | Command |
|------|---------|---------|
| PHPUnit | Unit/Feature tests | `./vendor/bin/phpunit` |
| Pest | Modern testing | `./vendor/bin/pest` |
| PHPStan | Static analysis | `./vendor/bin/phpstan analyse` |
| Psalm | Alternative static analysis | `./vendor/bin/psalm` |
| PHP-CS-Fixer | Code style | `./vendor/bin/php-cs-fixer fix` |
| PHPMD | Mess detector | `./vendor/bin/phpmd src text cleancode` |
| Assertion | PHPUnit | Pest |
|-----------|---------|------|
| Equality | `$this->assertSame()` | `expect()->toBe()` |
| Type | `$this->assertInstanceOf()` | `expect()->toBeInstanceOf()` |
| Array | `$this->assertContains()` | `expect()->toContain()` |
| Exception | `$this->expectException()` | `expect()->toThrow()` |
| Count | `$this->assertCount()` | `expect()->toHaveCount()` |
---
name: php-pro
description: Use when building PHP applications with modern PHP 8.3+ features, Laravel, or Symfony frameworks. Invokes strict typing, PHPStan level 9, async patterns with Swoole, and PSR standards. Creates controllers, configures middleware, generates migrations, writes PHPUnit/Pest tests, defines typed DTOs and value objects, sets up dependency injection, and scaffolds REST/GraphQL APIs. Use when working with Eloquent, Doctrine, Composer, Psalm, ReactPHP, or any PHP API development.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: language
triggers: PHP, Laravel, Symfony, Composer, PHPStan, PSR, PHP API, Eloquent, Doctrine
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, fastapi-expert
---
# PHP Pro
Senior PHP developer with deep expertise in PHP 8.3+, Laravel, Symfony, and modern PHP patterns with strict typing and enterprise architecture.
## Core Workflow
1. **Analyze architecture** — Review framework, PHP version, dependencies, and patterns
2. **Design models** — Create typed domain models, value objects, DTOs
3. **Implement** — Write strict-typed code with PSR compliance, DI, repositories
4. **Secure** — Add validation, authentication, XSS/SQL injection protection
5. **Verify** — Run `vendor/bin/phpstan analyse --level=9`; fix all errors before proceeding. Run `vendor/bin/phpunit` or `vendor/bin/pest`; enforce 80%+ coverage. Only deliver when both pass clean.
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Modern PHP | `references/modern-php-features.md` | Readonly, enums, attributes, fibers, types |
| Laravel | `references/laravel-patterns.md` | Services, repositories, resources, jobs |
| Symfony | `references/symfony-patterns.md` | DI, events, commands, voters |
| Async PHP | `references/async-patterns.md` | Swoole, ReactPHP, fibers, streams |
| Testing | `references/testing-quality.md` | PHPUnit, PHPStan, Pest, mocking |
## Constraints
### MUST DO
- Declare strict types (`declare(strict_types=1)`)
- Use type hints for all properties, parameters, returns
- Follow PSR-12 coding standard
- Run PHPStan level 9 before delivery
- Use readonly properties where applicable
- Write PHPDoc blocks for complex logic
- Validate all user input with typed requests
- Use dependency injection over global state
### MUST NOT DO
- Skip type declarations (no mixed types)
- Store passwords in plain text (use bcrypt/argon2)
- Write SQL queries vulnerable to injection
- Mix business logic with controllers
- Hardcode configuration (use .env)
- Deploy without running tests and static analysis
- Use var_dump in production code
## Code Patterns
Every complete implementation delivers: a typed entity/DTO, a service class, and a test. Use these as the baseline structure.
### Readonly DTO / Value Object
```php
<?php
declare(strict_types=1);
namespace App\DTO;
final readonly class CreateUserDTO
{
public function __construct(
public string $name,
public string $email,
public string $password,
) {}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'],
password: $data['password'],
);
}
}
```
### Typed Service with Constructor DI
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final class UserService
{
public function __construct(
private readonly UserRepositoryInterface $users,
) {}
public function create(CreateUserDTO $dto): User
{
return $this->users->create([
'name' => $dto->name,
'email' => $dto->email,
'password' => Hash::make($dto->password),
]);
}
}
```
### PHPUnit Test Structure
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use App\Services\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $users;
private UserService $service;
protected function setUp(): void
{
parent::setUp();
$this->users = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->users);
}
public function testCreateHashesPassword(): void
{
$dto = new CreateUserDTO('Alice', 'alice@example.com', 'secret');
$user = new User(['name' => 'Alice', 'email' => 'alice@example.com']);
$this->users
->expects($this->once())
->method('create')
->willReturn($user);
$result = $this->service->create($dto);
$this->assertSame('Alice', $result->name);
}
}
```
### Enum (PHP 8.1+)
```php
<?php
declare(strict_types=1);
namespace App\Enums;
enum UserStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Banned = 'banned';
public function label(): string
{
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Banned => 'Banned',
};
}
}
```
## Output Templates
When implementing a feature, deliver in this order:
1. Domain models (entities, value objects, enums)
2. Service/repository classes
3. Controller/API endpoints
4. Test files (PHPUnit/Pest)
5. Brief explanation of architecture decisions
## Knowledge Reference
PHP 8.3+, Laravel 11, Symfony 7, Composer, PHPStan, Psalm, PHPUnit, Pest, Eloquent ORM, Doctrine, PSR standards, Swoole, ReactPHP, Redis, MySQL/PostgreSQL, REST/GraphQL APIs
[Documentation](https://jeffallan.github.io/claude-skills/skills/language/php-pro/)
---
id: android
version: 1.0.0
name: Android + Kotlin (Jetpack Compose & Media3)
description: >
Provides expert knowledge for building modern native Android apps using Kotlin, Jetpack Compose, Material Design 3, MVI architecture, Hilt dependency injection, and Media3/ExoPlayer.
user-invokable: true
license: MIT
metadata:
author: lgzarturo
category: mobile
compatibility:
tools: [claude, codex, gemini, agy, opencode]
stacks:
languages: [kotlin, java]
frameworks: [android]
risk:
level: medium
can_execute_shell: true
can_modify_files: true
requires_network: false
inputs: []
outputs: []
quality:
reviewed_by: codeconductor-core
version: 0.1.0
---
# Android + Kotlin (Jetpack Compose & Media3)
This playbook defines the architecture, UI design system, performance guidelines, and testing strategy for native Android applications.
---
## 1. Lead Architect & Modularization
### Architecture (MVI / MVVM with Clean Architecture)
- Prefer **MVI (Model-View-Intent)** for state management. Ensure a strict unidirectional data flow:
- **UiState**: Single source of truth for the UI state.
- **UiIntent**: Actions initiated by the user or system.
- **UiEffect**: One-time events (navigation, toasts, snackbars).
- Separate the project into clean logical modules:
- `app`: Application entry point, launcher activity, global dependency injection configuration.
- `core`: Shared resources, network layer, media playback logic (ExoPlayer/Media3), storage.
- `feature`: Individual business features (isolated and independent of other features).
### Dependency Injection (Hilt)
- Annotate the Application class with `@HiltAndroidApp`.
- Annotate Activities and Fragments with `@AndroidEntryPoint`.
- Use constructor injection for ViewModels with `@HiltViewModel`.
---
## 2. Jetpack Compose UI/UX
### Recomposition & State Management
- Consume `StateFlow` from ViewModels safely using `collectAsStateWithLifecycle()` to respect the activity lifecycle.
- Keep data models immutable. Mark configuration classes with `@Immutable` or `@Stable` to prevent redundant recompositions.
- Use `contentType` and `key` in `LazyColumn` and `LazyRow` to optimize item recycling and rendering performance.
- Avoid fixed hardcoded margins. Apply `WindowInsets` to support modern edge-to-edge screens.
---
## 3. Performance & Multimedia (Media3 / ExoPlayer)
### Playback & Services
- Run `ExoPlayer` / `Media3` inside a robust `Foreground Service` (using `MediaSessionService`) to ensure playback continues when the app is in the background.
- Clean up resources: release player instances in `onDestroy` or when the service is stopped.
- Control wake locks strictly to avoid draining the user's battery when playback is paused.
### Kotlin Coroutines & Threading
- Always inject `CoroutineDispatcher` instances instead of hardcoding them:
- `Dispatchers.IO`: Database access, network operations, file reading.
- `Dispatchers.Default`: CPU-intensive operations (parsing JSON, filtering large lists).
- `Dispatchers.Main`: UI modifications and light interactions.
### R8 & Resource Optimization
- Enable shrinking and obfuscation in production builds:
- `isMinifyEnabled = true`
- `isShrinkResources = true`
- Convert all static images to the modern `WebP` format to minimize the application binary size.
---
## 4. Quality Assurance & Automated Testing
### Unit Testing
- Use JUnit 5 and MockK for mocking dependencies.
- Test ViewModels and Reducers by asserting StateFlow updates. Use `Turbine` to easily test Kotlin Flows.
- Mock all multimedia/ExoPlayer components to avoid loading actual audio/video resources in unit tests.
### UI Testing
- Create UI tests using Compose test rules: `createComposeRule()` or `createAndroidComposeRule<MainActivity>()`.
- Assert correct rendering, visibility states, and user interactions without executing real network calls.
---
## 5. Key Gradle & ADB Commands
Use these commands for building, checking code style, running tests, and profiling performance:
| Command | Action |
| ------- | ------ |
| `./gradlew init` | Initialize a new Gradle project structure. |
| `./gradlew app:dependencies` | List all dependencies of the application module. |
| `./gradlew assembleDebug` | Validate compilation and generate debug APK. |
| `./gradlew ktlintCheck` | Run KtLint check to enforce Kotlin style guidelines. |
| `./gradlew lintDebug` | Run Android Lint tool to inspect code quality and UI issues. |
| `./gradlew testDebugUnitTest` | Run unit tests for debug build variant. |
| `./gradlew connectedAndroidTest` | Run instrumented UI tests on connected emulator or device. |
| `./gradlew assembleRelease` | Generate release build variant with R8 optimizations. |
| `adb shell dumpsys batterystats` | Profile system battery consumption metrics. |
| `adb shell am dumpheap <package> heap.hprof` | Dump application heap memory file for profiling. |
# Eloquent ORM
## Model Patterns
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'slug',
'content',
'published_at',
'user_id',
];
protected $casts = [
'published_at' => 'datetime',
'metadata' => 'array',
'is_featured' => 'boolean',
];
// Accessor using new Attribute syntax (Laravel 9+)
protected function title(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
// Mutator for computed property
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => str($this->content)->limit(100),
);
}
}
```
## Relationships
```php
// One-to-Many
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();
}
public function oldestPost(): HasOne
{
return $this->hasOne(Post::class)->oldestOfMany();
}
}
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Inverse relationship
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// Many-to-Many with Pivot
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withPivot('expires_at', 'assigned_by')
->withTimestamps()
->using(RoleUser::class); // Custom pivot model
}
}
// Has Many Through
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
// Polymorphic Relations
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Post extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// Many-to-Many Polymorphic
class Tag extends Model
{
public function posts(): MorphToMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function videos(): MorphToMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
}
```
## Query Scopes
```php
class Post extends Model
{
// Local scope
public function scopePublished($query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopePopular($query, int $threshold = 100): void
{
$query->where('views', '>=', $threshold);
}
// Global scope
protected static function booted(): void
{
static::addGlobalScope('active', function ($query) {
$query->where('status', 'active');
});
}
}
// Usage
$posts = Post::published()->popular(500)->get();
// Custom Scope Class
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AncientScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('created_at', '<', now()->subYears(10));
}
}
// Apply in model
protected static function booted(): void
{
static::addGlobalScope(new AncientScope);
}
```
## Custom Casts
```php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Money implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): float
{
return $value / 100; // Store cents, return dollars
}
public function set($model, string $key, $value, array $attributes): int
{
return (int) ($value * 100);
}
}
// In model
protected $casts = [
'price' => Money::class,
];
```
## Query Optimization
```php
// Eager Loading (prevent N+1)
$posts = Post::with(['user', 'comments.user'])->get();
// Lazy Eager Loading
$posts = Post::all();
$posts->load('user');
// Eager Load with Constraints
$users = User::with(['posts' => function ($query) {
$query->where('published', true)->orderBy('created_at', 'desc');
}])->get();
// Count relationships efficiently
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
// Exists checks
$users = User::withExists('posts')->get();
// Chunk for large datasets
Post::chunk(100, function ($posts) {
foreach ($posts as $post) {
// Process post
}
});
// Lazy collection for memory efficiency
Post::lazy()->each(function ($post) {
// Process one at a time
});
```
## Model Events
```php
class Post extends Model
{
protected static function booted(): void
{
static::creating(function ($post) {
$post->slug = str($post->title)->slug();
});
static::updating(function ($post) {
if ($post->isDirty('title')) {
$post->slug = str($post->title)->slug();
}
});
static::deleted(function ($post) {
$post->images()->delete();
});
}
}
// Using Observers
namespace App\Observers;
class PostObserver
{
public function creating(Post $post): void
{
$post->user_id = auth()->id();
}
public function updated(Post $post): void
{
cache()->forget("post.{$post->id}");
}
}
// Register in AppServiceProvider
use App\Models\Post;
use App\Observers\PostObserver;
public function boot(): void
{
Post::observe(PostObserver::class);
}
```
## Advanced Queries
```php
// Subqueries
$users = User::select(['id', 'name'])
->addSelect(['latest_post_title' => Post::select('title')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])->get();
// When conditional queries
$posts = Post::query()
->when($search, fn ($query) => $query->where('title', 'like', "%{$search}%"))
->when($category, fn ($query) => $query->where('category_id', $category))
->get();
// Database transactions
DB::transaction(function () {
$user = User::create([...]);
$user->profile()->create([...]);
$user->assignRole('member');
});
// Pessimistic locking
$user = User::where('id', 1)->lockForUpdate()->first();
// Upserts
User::upsert(
[
['email' => 'john@example.com', 'name' => 'John'],
['email' => 'jane@example.com', 'name' => 'Jane'],
],
['email'], // Unique columns
['name'] // Columns to update
);
```
## Performance Tips
1. **Always eager load relationships** - Avoid N+1 queries
2. **Use chunking for large datasets** - Prevent memory exhaustion
3. **Index foreign keys** - Speed up joins
4. **Use select() to limit columns** - Reduce data transfer
5. **Cache expensive queries** - Use Redis/Memcached
6. **Use database indexing** - Add indexes in migrations
7. **Avoid using model events for heavy operations** - Use queues instead
8. **Use lazy collections** - For processing large datasets
# Livewire Components
## Component Patterns
```php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\WithFileUploads;
use App\Models\Post;
class PostList extends Component
{
use WithPagination, WithFileUploads;
public string $search = '';
public string $sortBy = 'created_at';
public string $sortDirection = 'desc';
public ?int $categoryId = null;
protected $queryString = [
'search' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'categoryId' => ['except' => null],
];
public function updatingSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
return view('livewire.post-list', [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->categoryId, fn($q) => $q->where('category_id', $this->categoryId))
->orderBy($this->sortBy, $this->sortDirection)
->paginate(10),
]);
}
}
```
## Blade Template
```blade
<div>
{{-- Search --}}
<input
type="text"
wire:model.debounce.300ms="search"
placeholder="Search posts..."
class="form-input"
>
{{-- Filter by category --}}
<select wire:model="categoryId">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
{{-- Sortable table --}}
<table>
<thead>
<tr>
<th wire:click="sortBy('title')" style="cursor: pointer">
Title
@if($sortBy === 'title')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
<th wire:click="sortBy('created_at')" style="cursor: pointer">
Date
@if($sortBy === 'created_at')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>{{ $post->created_at->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Pagination --}}
{{ $posts->links() }}
{{-- Loading states --}}
<div wire:loading wire:target="search">
Searching...
</div>
</div>
```
## Form Component
```php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Models\Post;
class PostForm extends Component
{
public ?Post $post = null;
public string $title = '';
public string $content = '';
public array $tags = [];
public $image;
protected function rules(): array
{
return [
'title' => 'required|min:3|max:255',
'content' => 'required|min:10',
'tags' => 'array|max:5',
'tags.*' => 'exists:tags,id',
'image' => 'nullable|image|max:2048',
];
}
public function mount(?Post $post = null): void
{
if ($post) {
$this->post = $post;
$this->title = $post->title;
$this->content = $post->content;
$this->tags = $post->tags->pluck('id')->toArray();
}
}
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
public function save(): void
{
$validated = $this->validate();
if ($this->post) {
$this->post->update($validated);
$message = 'Post updated successfully!';
} else {
$this->post = Post::create($validated);
$message = 'Post created successfully!';
}
if ($this->image) {
$this->post->update([
'image_path' => $this->image->store('posts', 'public'),
]);
}
$this->post->tags()->sync($this->tags);
session()->flash('message', $message);
$this->redirect(route('posts.show', $this->post));
}
public function render()
{
return view('livewire.post-form');
}
}
```
## Form Template
```blade
<form wire:submit.prevent="save">
{{-- Title --}}
<div>
<label for="title">Title</label>
<input
type="text"
wire:model.defer="title"
id="title"
class="@error('title') border-red-500 @enderror"
>
@error('title')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content">Content</label>
<textarea
wire:model.defer="content"
id="content"
class="@error('content') border-red-500 @enderror"
></textarea>
@error('content')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Tags --}}
<div>
<label>Tags</label>
@foreach($availableTags as $tag)
<label>
<input
type="checkbox"
wire:model="tags"
value="{{ $tag->id }}"
>
{{ $tag->name }}
</label>
@endforeach
@error('tags')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- File Upload --}}
<div>
<label>Image</label>
<input type="file" wire:model="image">
@error('image')
<span class="text-red-500">{{ $message }}</span>
@enderror
{{-- Upload progress --}}
<div wire:loading wire:target="image">
Uploading...
</div>
{{-- Preview --}}
@if ($image)
<img src="{{ $image->temporaryUrl() }}" alt="Preview">
@endif
</div>
{{-- Submit --}}
<button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</button>
</form>
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
```
## Real-time Validation
```php
class PostForm extends Component
{
public string $title = '';
protected $rules = [
'title' => 'required|min:3|unique:posts,title',
];
// Real-time validation
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
// Custom validation messages
protected $messages = [
'title.required' => 'The post title is required.',
'title.min' => 'The title must be at least 3 characters.',
'title.unique' => 'This title is already taken.',
];
// Custom attribute names
protected $validationAttributes = [
'title' => 'post title',
];
}
```
## Events
```php
// Emit event
class PostList extends Component
{
public function deletePost($postId): void
{
Post::find($postId)->delete();
$this->emit('postDeleted', $postId);
}
}
// Listen to event
class PostStats extends Component
{
protected $listeners = ['postDeleted' => 'updateStats'];
public function updateStats($postId): void
{
// Update statistics
}
}
// Emit to specific component
$this->emitTo('post-stats', 'refresh');
// Emit to parent/children
$this->emitUp('saved');
$this->emitSelf('refresh');
// Browser events
$this->dispatchBrowserEvent('post-saved', ['id' => $post->id]);
```
## Listen to Browser Events
```blade
<div
x-data
@post-saved.window="alert('Post saved!')"
>
<!-- content -->
</div>
<script>
window.addEventListener('post-saved', event => {
console.log('Post ID:', event.detail.id);
});
</script>
```
## Polling
```blade
{{-- Poll every 2 seconds --}}
<div wire:poll.2s>
Current time: {{ now() }}
</div>
{{-- Poll specific action --}}
<div wire:poll.5s="checkStatus">
Status: {{ $status }}
</div>
{{-- Keep polling until condition --}}
<div wire:poll.keep-alive.2s>
<!-- content -->
</div>
```
## Loading States
```blade
{{-- Basic loading state --}}
<div wire:loading>
Loading...
</div>
{{-- Target specific action --}}
<div wire:loading wire:target="save">
Saving...
</div>
{{-- Hide element while loading --}}
<div wire:loading.remove>
Content (hidden during load)
</div>
{{-- Delay loading indicator --}}
<div wire:loading.delay>
This appears after 200ms
</div>
{{-- Custom delay --}}
<div wire:loading.delay.longest>
This appears after 1s
</div>
{{-- Loading classes --}}
<button
wire:click="save"
wire:loading.class="opacity-50"
wire:loading.class.remove="bg-blue-500"
>
Save
</button>
{{-- Loading attributes --}}
<button
wire:click="save"
wire:loading.attr="disabled"
>
Save
</button>
```
## Traits
```php
// Pagination
use Livewire\WithPagination;
class PostList extends Component
{
use WithPagination;
public function render()
{
return view('livewire.post-list', [
'posts' => Post::paginate(10),
]);
}
}
// File uploads
use Livewire\WithFileUploads;
class UploadPhoto extends Component
{
use WithFileUploads;
public $photo;
public function save(): void
{
$this->validate([
'photo' => 'image|max:1024',
]);
$this->photo->store('photos');
}
}
```
## Authorization
```php
class PostForm extends Component
{
public Post $post;
public function mount(Post $post): void
{
$this->authorize('update', $post);
$this->post = $post;
}
public function save(): void
{
$this->authorize('update', $this->post);
// Save logic
}
}
```
## Performance Tips
1. **Use wire:model.defer** - Batch updates on form submit
2. **Lazy load components** - Use wire:init for heavy operations
3. **Cache computed properties** - Use #[Computed] attribute
4. **Disable polling when hidden** - Use wire:poll.visible
5. **Optimize queries** - Eager load relationships
6. **Use wire:key** - Prevent re-rendering entire lists
7. **Debounce input** - Use wire:model.debounce
8. **Use pagination** - Don't load all records at once
```php
use Livewire\Attributes\Computed;
class PostList extends Component
{
#[Computed]
public function posts()
{
return Post::with('user')->paginate(10);
}
public function render()
{
return view('livewire.post-list');
}
}
```
```blade
{{-- Access computed property --}}
@foreach($this->posts as $post)
<!-- content -->
@endforeach
```
# Queue System
## Job Patterns
```php
namespace App\Jobs;
use App\Models\Post;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public $maxExceptions = 3;
public $backoff = [60, 120, 300]; // Exponential backoff
public function __construct(
public Post $post,
public ?User $user = null,
) {}
public function handle(): void
{
// Process the post
$this->post->update(['processed' => true]);
// Can access injected dependencies
$analytics = app(AnalyticsService::class);
$analytics->trackPostProcessed($this->post);
}
public function failed(\Throwable $exception): void
{
// Handle job failure
\Log::error('Post processing failed', [
'post_id' => $this->post->id,
'error' => $exception->getMessage(),
]);
}
}
```
## Dispatching Jobs
```php
use App\Jobs\ProcessPost;
// Dispatch immediately
ProcessPost::dispatch($post);
// Dispatch to specific queue
ProcessPost::dispatch($post)->onQueue('processing');
// Delayed dispatch
ProcessPost::dispatch($post)->delay(now()->addMinutes(10));
// Dispatch after database commit
ProcessPost::dispatch($post)->afterCommit();
// Dispatch conditionally
ProcessPost::dispatchIf($condition, $post);
ProcessPost::dispatchUnless($condition, $post);
// Synchronous dispatch (no queue)
ProcessPost::dispatchSync($post);
// Dispatch after response
ProcessPost::dispatchAfterResponse($post);
```
## Job Chaining
```php
use App\Jobs\{OptimizeImage, GenerateThumbnail, PublishPost};
// Chain jobs
OptimizeImage::withChain([
new GenerateThumbnail($post),
new PublishPost($post),
])->dispatch($post);
// Catch failures in chain
Bus::chain([
new ProcessPost($post),
new NotifyUser($user),
])->catch(function (\Throwable $e) {
// Handle failure
})->dispatch();
```
## Job Batching
```php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new ProcessPost($post1),
new ProcessPost($post2),
new ProcessPost($post3),
])->then(function (Batch $batch) {
// All jobs completed successfully
})->catch(function (Batch $batch, \Throwable $e) {
// First batch job failure detected
})->finally(function (Batch $batch) {
// The batch has finished executing
})->name('Process Posts')
->allowFailures()
->dispatch();
// Check batch status
$batch = Bus::findBatch($batchId);
if ($batch->finished()) {
// Batch is complete
}
if ($batch->cancelled()) {
// Batch was cancelled
}
// Add jobs to existing batch
$batch->add([
new ProcessPost($post4),
]);
```
## Rate Limiting
```php
use Illuminate\Support\Facades\Redis;
class ProcessPost implements ShouldQueue
{
public function handle(): void
{
Redis::throttle('process-posts')
->block(0)
->allow(10)
->every(60)
->then(function () {
// Lock acquired, process job
}, function () {
// Could not acquire lock, release job back
$this->release(10);
});
}
}
// Or using middleware
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [new RateLimited('process-posts')];
}
```
## Job Middleware
```php
namespace App\Jobs\Middleware;
class RateLimitedByUser
{
public function handle($job, $next): void
{
Redis::throttle("user:{$job->user->id}")
->allow(10)
->every(60)
->then(function () use ($job, $next) {
$next($job);
}, function () use ($job) {
$job->release(10);
});
}
}
// Use in job
use App\Jobs\Middleware\RateLimitedByUser;
public function middleware(): array
{
return [new RateLimitedByUser];
}
// Skip middleware
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping($this->user->id))->expireAfter(180),
];
}
```
## Unique Jobs
```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessPost implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function __construct(
public Post $post,
) {}
public function uniqueId(): string
{
return $this->post->id;
}
}
// Or use unique until processing
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class ProcessPost implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}
```
## Failed Jobs
```php
// Retry failed job
php artisan queue:retry <job-id>
// Retry all failed jobs
php artisan queue:retry all
// Flush failed jobs
php artisan queue:flush
// Prune failed jobs
php artisan queue:prune-failed --hours=48
// Handle in code
use Illuminate\Support\Facades\Queue;
Queue::failing(function (JobFailed $event) {
\Log::error('Job failed', [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
```
## Queue Workers
```bash
# Start worker
php artisan queue:work
# Process specific queue
php artisan queue:work --queue=high,default
# Process one job
php artisan queue:work --once
# Stop worker gracefully
php artisan queue:restart
# Timeout settings
php artisan queue:work --timeout=60
# Memory limit
php artisan queue:work --memory=512
# Max jobs before restart
php artisan queue:work --max-jobs=1000
# Max time before restart
php artisan queue:work --max-time=3600
```
## Horizon Setup
```php
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'maxProcesses' => 10,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 512,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
'supervisor-2' => [
'connection' => 'redis',
'queue' => ['high', 'default'],
'balance' => 'auto',
'maxProcesses' => 5,
'tries' => 3,
],
],
],
];
// Start Horizon
php artisan horizon
// Terminate Horizon
php artisan horizon:terminate
// Pause workers
php artisan horizon:pause
// Continue workers
php artisan horizon:continue
// Check status
php artisan horizon:status
```
## Monitoring
```php
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
// In AppServiceProvider
public function boot(): void
{
Queue::before(function (JobProcessing $event) {
// Called before job is processed
});
Queue::after(function (JobProcessed $event) {
// Called after job is processed
\Log::info('Job processed', [
'job' => $event->job->resolveName(),
'time' => $event->job->processingTime(),
]);
});
Queue::failing(function (JobFailed $event) {
// Called when job fails
\Log::error('Job failed', [
'job' => $event->job->resolveName(),
'exception' => $event->exception,
]);
});
}
```
## Queue Configuration
```php
// config/queue.php
return [
'default' => env('QUEUE_CONNECTION', 'sync'),
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX'),
'queue' => env('SQS_QUEUE'),
'region' => env('AWS_DEFAULT_REGION'),
],
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
```
## Best Practices
1. **Keep jobs small and focused** - Single responsibility
2. **Make jobs idempotent** - Safe to run multiple times
3. **Use type hints** - Better error detection
4. **Set reasonable timeouts** - Prevent hanging jobs
5. **Monitor failed jobs** - Set up alerts
6. **Use batching for bulk operations** - Better performance
7. **Implement proper error handling** - Use failed() method
8. **Use unique jobs** - Prevent duplicate processing
9. **Queue long-running tasks** - Don't block requests
10. **Use Horizon for Redis queues** - Better monitoring
# Routing & API Resources
## Route Patterns
```php
// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
// Resource routes
Route::resource('posts', PostController::class);
// API resource (excludes create/edit)
Route::apiResource('posts', PostController::class);
// Partial resource
Route::resource('posts', PostController::class)->only(['index', 'show']);
Route::resource('posts', PostController::class)->except(['destroy']);
// Nested resources
Route::resource('posts.comments', CommentController::class);
// Route groups
Route::prefix('admin')->middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::resource('users', UserController::class);
});
// Named routes
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
// Route model binding
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
// Multiple bindings
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
return view('posts.show', compact('user', 'post'));
});
```
## API Routes
```php
// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
Route::prefix('v1')->group(function () {
// Public routes
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
});
// Rate limiting
Route::middleware('throttle:60,1')->group(function () {
Route::apiResource('posts', PostController::class);
});
```
## Controllers
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Models\Post;
use Illuminate\Http\Response;
class PostController extends Controller
{
public function index()
{
$posts = Post::with('user')
->published()
->paginate(15);
return new PostCollection($posts);
}
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return new PostResource($post);
}
public function show(Post $post)
{
$post->load(['user', 'comments.user']);
return new PostResource($post);
}
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
return new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->noContent();
}
}
```
## Form Requests
```php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or check user permissions
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'content' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'title.required' => 'Please provide a post title',
'slug.unique' => 'This slug is already taken',
];
}
// Prepare data before validation
protected function prepareForValidation(): void
{
$this->merge([
'slug' => str($this->title)->slug(),
]);
}
}
class UpdatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'slug' => [
'sometimes',
'string',
Rule::unique('posts', 'slug')->ignore($this->post)
],
'content' => ['sometimes', 'string'],
];
}
}
```
## API Resources
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when($request->route()->named('posts.show'), $this->content),
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
// Relationships
'author' => new UserResource($this->whenLoaded('user')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'comments_count' => $this->when($this->comments_count !== null, $this->comments_count),
// Conditional fields
'is_published' => $this->when($request->user()?->isAdmin(), $this->isPublished()),
// Pivot data
'role' => $this->whenPivotLoaded('role_user', function () {
return $this->pivot->role_name;
}),
// Links
'links' => [
'self' => route('api.posts.show', $this->id),
],
];
}
public function with(Request $request): array
{
return [
'meta' => [
'version' => '1.0.0',
],
];
}
}
```
## Resource Collections
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
],
'links' => [
'self' => $request->url(),
],
];
}
}
// Or use anonymous collection
return PostResource::collection($posts);
```
## Middleware
```php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next)
{
if (!$request->user()?->isAdmin()) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
// Register in app/Http/Kernel.php
protected $middlewareAliases = [
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
];
// Use in routes
Route::middleware('admin')->group(function () {
Route::resource('users', UserController::class);
});
```
## Response Helpers
```php
// JSON responses
return response()->json(['data' => $posts], 200);
// Created response
return response()->json($post, 201);
// No content
return response()->noContent();
// Custom headers
return response()->json($data)->header('X-Custom-Header', 'Value');
// Download
return response()->download($pathToFile);
// Stream
return response()->streamDownload(function () {
echo 'CSV content...';
}, 'export.csv');
```
## Route Caching
```bash
# Generate route cache
php artisan route:cache
# Clear route cache
php artisan route:clear
# List all routes
php artisan route:list
# Filter routes
php artisan route:list --name=api
php artisan route:list --path=posts
```
## API Versioning
```php
// routes/api.php
Route::prefix('v1')->name('v1.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
Route::prefix('v2')->name('v2.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});
```
## CORS Configuration
```php
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000'],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
```
# Testing
## Feature Tests
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\{User, Post};
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'Test Post',
'content' => 'This is a test post content.',
]);
$response->assertStatus(201)
->assertJson([
'data' => [
'title' => 'Test Post',
],
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
public function test_guest_cannot_create_post(): void
{
$response = $this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$response->assertStatus(401);
}
public function test_post_requires_valid_data(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'AB', // Too short
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']);
}
public function test_user_can_view_their_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/api/posts');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at'],
],
]);
}
public function test_user_can_update_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
'content' => $post->content,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title',
]);
}
public function test_user_cannot_update_others_post(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
]);
$response->assertStatus(403);
}
}
```
## Unit Tests
```php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostServiceTest extends TestCase
{
use RefreshDatabase;
public function test_generates_unique_slug(): void
{
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post', $slug);
}
public function test_increments_slug_on_duplicate(): void
{
Post::factory()->create(['slug' => 'test-post']);
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post-1', $slug);
}
public function test_post_excerpt_returns_limited_content(): void
{
$post = new Post(['content' => str_repeat('a', 200)]);
$excerpt = $post->excerpt;
$this->assertLessThanOrEqual(100, strlen($excerpt));
}
}
```
## Pest PHP
```php
<?php
use App\Models\{User, Post};
it('allows authenticated users to create posts', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])
->assertStatus(201);
expect(Post::count())->toBe(1);
});
it('prevents guests from creating posts', function () {
$this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])->assertStatus(401);
});
test('post requires title and content', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [])
->assertJsonValidationErrors(['title', 'content']);
});
// Datasets
it('validates title length', function (string $title, bool $shouldPass) {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => $title,
'content' => 'Content',
]);
if ($shouldPass) {
$response->assertStatus(201);
} else {
$response->assertJsonValidationErrors(['title']);
}
})->with([
['AB', false], // Too short
['ABC', true], // Minimum valid
[str_repeat('A', 255), true], // Maximum valid
[str_repeat('A', 256), false], // Too long
]);
// Hooks
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
// Cleanup
});
```
## Factories
```php
namespace Database\Factories;
use App\Models\{User, Category};
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'slug' => fake()->slug(),
'content' => fake()->paragraphs(3, true),
'excerpt' => fake()->text(100),
'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
'user_id' => User::factory(),
'category_id' => Category::factory(),
];
}
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(),
]);
}
public function forUser(User $user): static
{
return $this->state(fn (array $attributes) => [
'user_id' => $user->id,
]);
}
public function configure(): static
{
return $this->afterCreating(function (Post $post) {
$post->tags()->attach(
Tag::factory()->count(3)->create()
);
});
}
}
// Usage
$post = Post::factory()->create();
$unpublished = Post::factory()->unpublished()->create();
$posts = Post::factory()->count(10)->create();
$userPosts = Post::factory()->forUser($user)->count(5)->create();
// With relationships
$post = Post::factory()
->has(Comment::factory()->count(3))
->create();
// For relationship
$posts = Post::factory()
->count(3)
->for($user)
->create();
```
## Mocking
```php
use App\Services\ExternalApiService;
use Illuminate\Support\Facades\Http;
public function test_fetches_data_from_external_api(): void
{
Http::fake([
'api.example.com/*' => Http::response([
'data' => ['id' => 1, 'name' => 'Test'],
], 200),
]);
$service = new ExternalApiService();
$result = $service->fetchData();
$this->assertEquals('Test', $result['name']);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/data' &&
$request->hasHeader('Authorization');
});
}
// Mock events
use Illuminate\Support\Facades\Event;
Event::fake([PostCreated::class]);
// Test code that dispatches events
Event::assertDispatched(PostCreated::class, function ($event) {
return $event->post->id === 1;
});
// Mock queues
use Illuminate\Support\Facades\Queue;
Queue::fake();
// Test code that dispatches jobs
Queue::assertPushed(ProcessPost::class);
Queue::assertPushed(ProcessPost::class, 2);
Queue::assertPushed(ProcessPost::class, function ($job) {
return $job->post->id === 1;
});
// Mock notifications
use Illuminate\Support\Facades\Notification;
Notification::fake();
// Test code that sends notifications
Notification::assertSentTo($user, PostPublished::class);
// Mock storage
use Illuminate\Support\Facades\Storage;
Storage::fake('public');
// Test file upload
Storage::disk('public')->assertExists('file.jpg');
Storage::disk('public')->assertMissing('missing.jpg');
```
## Database Testing
```php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class PostTest extends TestCase
{
use RefreshDatabase; // Migrate database before each test
// Or use transactions
use DatabaseTransactions; // Rollback after each test
public function test_database_assertions(): void
{
$post = Post::factory()->create([
'title' => 'Test Post',
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
]);
$post->delete();
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
}
public function test_model_exists(): void
{
$post = Post::factory()->create();
$this->assertModelExists($post);
$post->delete();
$this->assertModelMissing($post);
}
}
```
## API Testing
```php
public function test_api_returns_paginated_posts(): void
{
Post::factory()->count(30)->create();
$response = $this->get('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content'],
],
'meta' => ['total', 'current_page', 'last_page'],
'links' => ['first', 'last', 'prev', 'next'],
])
->assertJsonCount(15, 'data'); // Default per page
}
public function test_api_filters_posts_by_category(): void
{
$category = Category::factory()->create();
Post::factory()->count(5)->create(['category_id' => $category->id]);
Post::factory()->count(5)->create();
$response = $this->get("/api/posts?category={$category->id}");
$response->assertJsonCount(5, 'data')
->assertJson([
'data' => [
['category_id' => $category->id],
],
]);
}
```
## Authentication Testing
```php
use Laravel\Sanctum\Sanctum;
public function test_authenticated_user_can_access_endpoint(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$response = $this->get('/api/user');
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function test_user_with_wrong_ability_cannot_access(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['view-posts']);
$response = $this->post('/api/posts', [
'title' => 'Test',
'content' => 'Content',
]);
$response->assertStatus(403);
}
```
## Running Tests
```bash
# Run all tests
php artisan test
# Run specific test
php artisan test --filter=test_user_can_create_post
# Run test file
php artisan test tests/Feature/PostTest.php
# Parallel testing
php artisan test --parallel
# With coverage
php artisan test --coverage
# Coverage minimum
php artisan test --coverage --min=80
# Stop on failure
php artisan test --stop-on-failure
# Pest specific
./vendor/bin/pest
./vendor/bin/pest --filter=PostTest
./vendor/bin/pest --coverage
```
## Best Practices
1. **Use RefreshDatabase** - Clean database for each test
2. **Use factories** - Don't manually create test data
3. **Test one thing** - Each test should verify one behavior
4. **Use descriptive names** - test_user_can_create_post
5. **AAA pattern** - Arrange, Act, Assert
6. **Mock external services** - Don't make real API calls
7. **Fake queues and events** - Test async code synchronously
8. **Test edge cases** - Invalid data, permissions, etc.
9. **Achieve >85% coverage** - Test critical paths
10. **Run tests in CI/CD** - Automate test execution
---
name: laravel-specialist
description: Build and configure Laravel 10+ applications, including creating Eloquent models and relationships, implementing Sanctum authentication, configuring Horizon queues, designing RESTful APIs with API resources, and building reactive interfaces with Livewire. Use when creating Laravel models, setting up queue workers, implementing Sanctum auth flows, building Livewire components, optimising Eloquent queries, or writing Pest/PHPUnit tests for Laravel features.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: backend
triggers: Laravel, Eloquent, PHP framework, Laravel API, Artisan, Blade templates, Laravel queues, Livewire, Laravel testing, Sanctum, Horizon
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, test-master, devops-engineer, security-reviewer
---
# Laravel Specialist
Senior Laravel specialist with deep expertise in Laravel 10+, Eloquent ORM, and modern PHP 8.2+ development.
## Core Workflow
1. **Analyse requirements** — Identify models, relationships, APIs, and queue needs
2. **Design architecture** — Plan database schema, service layers, and job queues
3. **Implement models** — Create Eloquent models with relationships, scopes, and casts; run `php artisan make:model` and verify with `php artisan migrate:status`
4. **Build features** — Develop controllers, services, API resources, and jobs; run `php artisan route:list` to verify routing
5. **Test thoroughly** — Write feature and unit tests; run `php artisan test` before considering any step complete (target >85% coverage)
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Eloquent ORM | `references/eloquent.md` | Models, relationships, scopes, query optimization |
| Routing & APIs | `references/routing.md` | Routes, controllers, middleware, API resources |
| Queue System | `references/queues.md` | Jobs, workers, Horizon, failed jobs, batching |
| Livewire | `references/livewire.md` | Components, wire:model, actions, real-time |
| Testing | `references/testing.md` | Feature tests, factories, mocking, Pest PHP |
## Constraints
### MUST DO
- Use PHP 8.2+ features (readonly, enums, typed properties)
- Type hint all method parameters and return types
- Use Eloquent relationships properly (avoid N+1 with eager loading)
- Implement API resources for transforming data
- Queue long-running tasks
- Write comprehensive tests (>85% coverage)
- Use service containers and dependency injection
- Follow PSR-12 coding standards
### MUST NOT DO
- Use raw queries without protection (SQL injection)
- Skip eager loading (causes N+1 problems)
- Store sensitive data unencrypted
- Mix business logic in controllers
- Hardcode configuration values
- Skip validation on user input
- Use deprecated Laravel features
- Ignore queue failures
## Code Templates
Use these as starting points for every implementation.
### Eloquent Model
```php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['title', 'body', 'status', 'user_id'];
protected $casts = [
'status' => PostStatus::class, // backed enum
'published_at' => 'immutable_datetime',
];
// Relationships — always eager-load via ::with() at call site
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Local scope
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PostStatus::Published);
}
}
```
### Migration
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
```
### API Resource
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'status' => $this->status->value,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
];
}
}
```
### Queued Job
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class PublishPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private readonly Post $post,
) {}
public function handle(): void
{
$this->post->update([
'status' => PostStatus::Published,
'published_at' => now(),
]);
}
public function failed(\Throwable $e): void
{
// Log or notify — never silently swallow failures
logger()->error('PublishPost failed', ['post' => $this->post->id, 'error' => $e->getMessage()]);
}
}
```
### Feature Test (Pest)
```php
<?php
use App\Models\Post;
use App\Models\User;
it('returns a published post for authenticated users', function (): void {
$user = User::factory()->create();
$post = Post::factory()->published()->for($user, 'author')->create();
$response = $this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$response->assertOk()
->assertJsonPath('data.status', 'published')
->assertJsonPath('data.author.id', $user->id);
});
it('queues a publish job when a draft is submitted', function (): void {
Queue::fake();
$user = User::factory()->create();
$post = Post::factory()->draft()->for($user, 'author')->create();
$this->actingAs($user)
->postJson("/api/posts/{$post->id}/publish")
->assertAccepted();
Queue::assertPushed(PublishPost::class, fn ($job) => $job->post->is($post));
});
```
## Validation Checkpoints
Run these at each workflow stage to confirm correctness before proceeding:
| Stage | Command | Expected Result |
|-------|---------|-----------------|
| After migration | `php artisan migrate:status` | All migrations show `Ran` |
| After routing | `php artisan route:list --path=api` | New routes appear with correct verbs |
| After job dispatch | `php artisan queue:work --once` | Job processes without exception |
| After implementation | `php artisan test --coverage` | >85% coverage, 0 failures |
| Before PR | `./vendor/bin/pint --test` | PSR-12 linting passes |
## Knowledge Reference
Laravel 10+, Eloquent ORM, PHP 8.2+, API resources, Sanctum/Passport, queues, Horizon, Livewire, Inertia, Octane, Pest/PHPUnit, Redis, broadcasting, events/listeners, notifications, task scheduling
[Documentation](https://jeffallan.github.io/claude-skills/skills/backend/laravel-specialist/)
# Async PHP Patterns
## Swoole HTTP Server
```php
<?php
declare(strict_types=1);
use Swoole\HTTP\Server;
use Swoole\HTTP\Request;
use Swoole\HTTP\Response;
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4,
'max_request' => 10000,
'task_worker_num' => 2,
'enable_coroutine' => true,
]);
$server->on('start', function (Server $server) {
echo "Swoole HTTP server started at http://0.0.0.0:9501\n";
});
$server->on('request', function (Request $request, Response $response) {
$response->header('Content-Type', 'application/json');
match ($request->server['request_uri']) {
'/api/users' => handleUsers($request, $response),
'/api/health' => $response->end(json_encode(['status' => 'healthy'])),
default => $response->status(404)->end(json_encode(['error' => 'Not found'])),
};
});
function handleUsers(Request $request, Response $response): void
{
// Coroutine for concurrent DB queries
go(function () use ($response) {
$users = queryDatabase('SELECT * FROM users LIMIT 10');
$response->end(json_encode(['data' => $users]));
});
}
$server->start();
```
## Swoole Coroutines
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Http\Client;
// Concurrent HTTP requests
Coroutine\run(function () {
$results = [];
// Create multiple coroutines
$wg = new Coroutine\WaitGroup();
$urls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments',
];
foreach ($urls as $url) {
$wg->add();
go(function () use ($url, &$results, $wg) {
$client = new Client(parse_url($url, PHP_URL_HOST), 443, true);
$client->set(['timeout' => 5]);
$client->get(parse_url($url, PHP_URL_PATH));
$results[$url] = [
'status' => $client->statusCode,
'body' => $client->body,
];
$client->close();
$wg->done();
});
}
$wg->wait();
print_r($results);
});
```
## Swoole Async MySQL
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\MySQL;
Coroutine\run(function () {
$mysql = new MySQL();
$connected = $mysql->connect([
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'root',
'password' => 'password',
'database' => 'test',
]);
if (!$connected) {
throw new \RuntimeException($mysql->connect_error);
}
// Async query
$result = $mysql->query('SELECT * FROM users WHERE active = 1');
foreach ($result as $row) {
echo "User: {$row['name']}\n";
}
// Prepared statements
$stmt = $mysql->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$user = $stmt->fetchAll();
$mysql->close();
});
```
## Swoole Channel (Communication)
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
Coroutine\run(function () {
$channel = new Channel(10); // Buffer size: 10
// Producer
go(function () use ($channel) {
for ($i = 1; $i <= 5; $i++) {
$channel->push("Task {$i}");
echo "Produced: Task {$i}\n";
Coroutine::sleep(0.5);
}
$channel->close();
});
// Consumer
go(function () use ($channel) {
while (true) {
$task = $channel->pop();
if ($task === false && $channel->errCode === SWOOLE_CHANNEL_CLOSED) {
break;
}
echo "Consumed: {$task}\n";
Coroutine::sleep(1);
}
});
});
```
## ReactPHP Event Loop
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\EventLoop\Loop;
use React\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;
// HTTP Server
$server = new React\Http\HttpServer(function (ServerRequestInterface $request) {
return new Response(
200,
['Content-Type' => 'application/json'],
json_encode([
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'timestamp' => time(),
])
);
});
$socket = new React\Socket\SocketServer('0.0.0.0:8080');
$server->listen($socket);
echo "Server running at http://0.0.0.0:8080\n";
// Periodic timer
Loop::addPeriodicTimer(5.0, function () {
echo "Heartbeat: " . date('H:i:s') . "\n";
});
// One-time timer
Loop::addTimer(10.0, function () {
echo "This runs once after 10 seconds\n";
});
```
## ReactPHP Async MySQL
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\MySQL\Factory;
use React\MySQL\QueryResult;
$factory = new Factory();
$connection = $factory->createLazyConnection('root:password@localhost/database');
$connection->query('SELECT * FROM users WHERE active = 1')
->then(
function (QueryResult $result) {
echo "Found " . count($result->resultRows) . " users\n";
foreach ($result->resultRows as $row) {
echo "User: {$row['name']}\n";
}
},
function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
}
);
// Prepared statements
$connection->query('SELECT * FROM users WHERE id = ?', [42])
->then(function (QueryResult $result) {
$user = $result->resultRows[0] ?? null;
var_dump($user);
});
```
## ReactPHP Promises
```php
<?php
declare(strict_types=1);
use React\Promise\Promise;
use React\Promise\Deferred;
use function React\Promise\all;
// Creating promises
function fetchUser(int $id): Promise
{
$deferred = new Deferred();
// Simulate async operation
Loop::addTimer(1.0, function () use ($deferred, $id) {
$deferred->resolve([
'id' => $id,
'name' => "User {$id}",
]);
});
return $deferred->promise();
}
// Using promises
fetchUser(42)
->then(function ($user) {
echo "Got user: {$user['name']}\n";
return fetchUserPosts($user['id']);
})
->then(function ($posts) {
echo "Got " . count($posts) . " posts\n";
})
->catch(function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
});
// Parallel promises
all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
])->then(function ($users) {
echo "Fetched " . count($users) . " users\n";
});
```
## PHP Fibers (Native PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Simple async function using fibers
function async(callable $callback): Fiber
{
return new Fiber($callback);
}
function await(Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
if ($fiber->isTerminated()) {
return $fiber->getReturn();
}
return $fiber->resume();
}
// Simulate async I/O
function fetchData(string $url): Fiber
{
return async(function () use ($url) {
echo "Fetching: {$url}\n";
Fiber::suspend('pending');
// Simulate network delay
sleep(1);
return "Data from {$url}";
});
}
// Usage
$fiber1 = fetchData('https://api.example.com/users');
$fiber2 = fetchData('https://api.example.com/posts');
await($fiber1);
await($fiber2);
$result1 = await($fiber1);
$result2 = await($fiber2);
echo "{$result1}\n";
echo "{$result2}\n";
```
## Amphp Framework
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Amp\Http\Server\HttpServer;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
use Amp\Http\Server\Router;
use Amp\Socket\Server as SocketServer;
use function Amp\async;
use function Amp\Future\await;
// HTTP Server with Amphp
$router = new Router();
$router->addRoute('GET', '/api/users', function (Request $request): Response {
// Concurrent database queries
$users = await([
async(fn() => queryUsers()),
async(fn() => queryUserStats()),
]);
return new Response(
status: 200,
headers: ['content-type' => 'application/json'],
body: json_encode(['users' => $users[0], 'stats' => $users[1]]),
);
});
$server = new HttpServer(
servers: [SocketServer::listen('0.0.0.0:8080')],
requestHandler: $router,
);
$server->start();
```
## Quick Reference
| Technology | Use Case | Performance |
|------------|----------|-------------|
| Swoole | High-performance servers, WebSockets | Very High |
| ReactPHP | Event-driven apps, real-time | High |
| Amphp | Modern async framework | High |
| Fibers | Native async (PHP 8.1+) | Medium |
| Generators | Simple async patterns | Medium |
| Feature | Swoole | ReactPHP | Amphp |
|---------|--------|----------|-------|
| Coroutines | Yes | No (Promises) | Yes (Fibers) |
| HTTP Server | Built-in | Via package | Via package |
| WebSockets | Built-in | Via package | Via package |
| Extension | Required | Not required | Not required |
| Learning Curve | Medium | Low | Medium |
# Laravel Patterns
## Service Layer Pattern
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTOs\CreateUserData;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailService $emailService,
) {}
public function createUser(CreateUserData $data): User
{
$user = $this->userRepository->create([
'name' => $data->name,
'email' => $data->email,
'password' => Hash::make($data->password),
]);
$this->emailService->sendWelcomeEmail($user);
return $user;
}
public function suspendUser(int $userId, string $reason): void
{
$user = $this->userRepository->findOrFail($userId);
$this->userRepository->update($user->id, [
'status' => UserStatus::SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
$this->emailService->sendSuspensionNotice($user, $reason);
}
}
```
## Repository Pattern
```php
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
interface UserRepositoryInterface
{
public function findOrFail(int $id): User;
public function findByEmail(string $email): ?User;
public function create(array $data): User;
public function update(int $id, array $data): User;
public function delete(int $id): void;
public function getActive(): Collection;
}
final class UserRepository implements UserRepositoryInterface
{
public function findOrFail(int $id): User
{
return User::findOrFail($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function create(array $data): User
{
return User::create($data);
}
public function update(int $id, array $data): User
{
$user = $this->findOrFail($id);
$user->update($data);
return $user->fresh();
}
public function delete(int $id): void
{
$this->findOrFail($id)->delete();
}
public function getActive(): Collection
{
return User::where('status', UserStatus::ACTIVE)
->orderBy('created_at', 'desc')
->get();
}
}
```
## Form Requests with Enums
```php
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
final class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', User::class) ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', new Enum(UserRole::class)],
'settings' => ['sometimes', 'array'],
'settings.theme' => ['string', Rule::in(['light', 'dark'])],
];
}
public function toDto(): CreateUserData
{
return new CreateUserData(
name: $this->validated('name'),
email: $this->validated('email'),
password: $this->validated('password'),
role: UserRole::from($this->validated('role')),
);
}
}
```
## API Resources
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\User
*/
final class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status->value,
'role' => $this->role->value,
'created_at' => $this->created_at->toIso8601String(),
// Conditional relationships
'posts' => PostResource::collection($this->whenLoaded('posts')),
'profile' => new ProfileResource($this->whenLoaded('profile')),
// Conditional attributes
'is_admin' => $this->when($this->role === UserRole::ADMIN, true),
// Pivot data
'team_role' => $this->whenPivotLoaded('team_user', fn() =>
$this->pivot->role
),
];
}
}
final class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),
],
];
}
}
```
## Controllers with DTOs
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class UserController extends Controller
{
public function __construct(
private readonly UserService $userService,
) {}
public function index(): AnonymousResourceCollection
{
$users = User::with('profile')
->where('status', UserStatus::ACTIVE)
->paginate(20);
return UserResource::collection($users);
}
public function store(CreateUserRequest $request): JsonResponse
{
$user = $this->userService->createUser($request->toDto());
return (new UserResource($user))
->response()
->setStatusCode(201);
}
public function show(User $user): UserResource
{
$user->load(['posts', 'profile']);
return new UserResource($user);
}
public function destroy(User $user): JsonResponse
{
$this->authorize('delete', $user);
$this->userService->deleteUser($user->id);
return response()->json(null, 204);
}
}
```
## Jobs & Queues
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(
private readonly int $userId,
) {}
public function handle(EmailService $emailService): void
{
$user = User::findOrFail($this->userId);
$emailService->sendWelcomeEmail($user);
}
public function failed(\Throwable $exception): void
{
\Log::error('Failed to send welcome email', [
'user_id' => $this->userId,
'error' => $exception->getMessage(),
]);
}
}
// Dispatching jobs
SendWelcomeEmail::dispatch($user->id);
SendWelcomeEmail::dispatch($user->id)->delay(now()->addMinutes(5));
SendWelcomeEmail::dispatch($user->id)->onQueue('emails');
```
## Event Listeners
```php
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final readonly class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
) {}
}
namespace App\Listeners;
use App\Events\UserRegistered;
use App\Jobs\SendWelcomeEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
final class SendWelcomeNotification implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
SendWelcomeEmail::dispatch($event->user->id);
}
}
// In EventServiceProvider
protected $listen = [
UserRegistered::class => [
SendWelcomeNotification::class,
UpdateUserStatistics::class,
],
];
```
## Quick Reference
| Pattern | Purpose | File Location |
|---------|---------|---------------|
| Service | Business logic | `app/Services/` |
| Repository | Data access | `app/Repositories/` |
| Form Request | Validation | `app/Http/Requests/` |
| Resource | API responses | `app/Http/Resources/` |
| Job | Async tasks | `app/Jobs/` |
| Event | Domain events | `app/Events/` |
| DTO | Data transfer | `app/DTOs/` |
| Policy | Authorization | `app/Policies/` |
# Modern PHP 8.3+ Features
## Strict Types & Type Declarations
```php
<?php
declare(strict_types=1);
namespace App\Domain\User;
final readonly class User
{
public function __construct(
public int $id,
public string $email,
public UserStatus $status,
public \DateTimeImmutable $createdAt,
) {}
}
function calculateTotal(int $price, float $taxRate): float
{
return $price * (1 + $taxRate);
}
// Union types
function processId(int|string $id): string
{
return is_int($id) ? (string)$id : $id;
}
// Intersection types
interface Timestamped {}
interface Authenticatable {}
function handleUser(Timestamped&Authenticatable $user): void {}
```
## Enums with Methods
```php
<?php
declare(strict_types=1);
enum UserStatus: string
{
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case DELETED = 'deleted';
public function label(): string
{
return match($this) {
self::ACTIVE => 'Active User',
self::SUSPENDED => 'Suspended',
self::DELETED => 'Deleted User',
};
}
public function canLogin(): bool
{
return $this === self::ACTIVE;
}
public static function fromString(string $value): self
{
return self::from(strtolower($value));
}
}
enum HttpStatus: int
{
case OK = 200;
case CREATED = 201;
case BAD_REQUEST = 400;
case UNAUTHORIZED = 401;
case NOT_FOUND = 404;
case SERVER_ERROR = 500;
public function isSuccess(): bool
{
return $this->value >= 200 && $this->value < 300;
}
}
```
## Readonly Properties & Classes
```php
<?php
declare(strict_types=1);
// Readonly class (PHP 8.2+)
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
// Individual readonly properties
class Configuration
{
public function __construct(
public readonly string $apiKey,
public readonly string $apiSecret,
private string $cache = '',
) {}
}
```
## Attributes (Metadata)
```php
<?php
declare(strict_types=1);
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public array $middleware = [],
) {}
}
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final readonly class Validate
{
public function __construct(
public ?string $rule = null,
public ?int $min = null,
public ?int $max = null,
) {}
}
// Using attributes
#[Route('/api/users', method: 'POST', middleware: ['auth'])]
final class CreateUserController
{
public function __invoke(CreateUserRequest $request): JsonResponse
{
// ...
}
}
class UserDto
{
#[Validate(rule: 'email')]
public string $email;
#[Validate(min: 8, max: 100)]
public string $password;
}
```
## First-Class Callables
```php
<?php
declare(strict_types=1);
class UserService
{
public function findById(int $id): ?User {}
public function create(array $data): User {}
}
$service = new UserService();
// PHP 8.1+ first-class callable syntax
$finder = $service->findById(...);
$user = $finder(42);
// Array operations
$numbers = [1, 2, 3, 4, 5];
$doubled = array_map(fn($n) => $n * 2, $numbers);
// Named arguments with callable
$result = array_filter(
array: $numbers,
callback: fn($n) => $n % 2 === 0,
);
```
## Match Expressions
```php
<?php
declare(strict_types=1);
function getStatusColor(UserStatus $status): string
{
return match ($status) {
UserStatus::ACTIVE => 'green',
UserStatus::SUSPENDED => 'yellow',
UserStatus::DELETED => 'red',
};
}
function calculateShipping(int $weight, string $zone): float
{
return match (true) {
$weight < 1000 => 5.00,
$weight < 5000 && $zone === 'local' => 10.00,
$weight < 5000 => 15.00,
default => 25.00,
};
}
// Match with multiple conditions
function getHttpMessage(int $code): string
{
return match ($code) {
200, 201, 204 => 'Success',
400, 422 => 'Client Error',
401, 403 => 'Unauthorized',
500, 502, 503 => 'Server Error',
default => 'Unknown',
};
}
```
## Fibers (PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Basic fiber example
$fiber = new \Fiber(function (): void {
$value = \Fiber::suspend('fiber started');
echo "Received: {$value}\n";
\Fiber::suspend('second suspend');
echo "Fiber completed\n";
});
$result1 = $fiber->start();
echo "First result: {$result1}\n";
$result2 = $fiber->resume('data from main');
echo "Second result: {$result2}\n";
$fiber->resume('final data');
// Async-style with fibers
function async(callable $callback): \Fiber
{
return new \Fiber($callback);
}
function await(\Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
return $fiber->resume();
}
```
## Never Type
```php
<?php
declare(strict_types=1);
function redirect(string $url): never
{
header("Location: {$url}");
exit;
}
function abort(int $code, string $message): never
{
http_response_code($code);
echo json_encode(['error' => $message]);
exit;
}
class NotFoundException extends \Exception
{
public static function throw(string $resource): never
{
throw new self("Resource not found: {$resource}");
}
}
```
## Quick Reference
| Feature | PHP Version | Usage |
|---------|-------------|-------|
| Readonly properties | 8.1+ | `public readonly string $name` |
| Readonly classes | 8.2+ | `readonly class User {}` |
| Enums | 8.1+ | `enum Status: string {}` |
| First-class callables | 8.1+ | `$fn = $obj->method(...)` |
| Never type | 8.1+ | `function exit(): never` |
| Fibers | 8.1+ | `new \Fiber(fn() => ...)` |
| Pure intersection types | 8.1+ | `A&B $param` |
| DNF types | 8.2+ | `(A&B)\|C $param` |
| Constants in traits | 8.2+ | `trait T { const X = 1; }` |
# Symfony Patterns
## Dependency Injection
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepositoryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public function createUser(string $email, string $password): User
{
$user = new User($email, password_hash($password, PASSWORD_ARGON2ID));
$this->userRepository->save($user);
$this->logger->info('User created', ['email' => $email]);
return $user;
}
}
```
## Service Configuration (services.yaml)
```yaml
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $projectDir: '%kernel.project_dir%'
bool $isDebug: '%kernel.debug%'
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Interface binding
App\Repository\UserRepositoryInterface:
class: App\Repository\DoctrineUserRepository
# Service with specific configuration
App\Service\PaymentService:
arguments:
$apiKey: '%env(PAYMENT_API_KEY)%'
$timeout: 30
# Tagged services
App\EventSubscriber\:
resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber']
```
## Controllers with Attributes
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\DTO\CreateUserRequest;
use App\Entity\User;
use App\Service\UserService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/users', name: 'api_users_')]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserService $userService,
) {}
#[Route('', name: 'list', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function list(): JsonResponse
{
$users = $this->userService->getAllUsers();
return $this->json($users, Response::HTTP_OK, [], [
'groups' => ['user:read'],
]);
}
#[Route('', name: 'create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(
#[MapRequestPayload] CreateUserRequest $request
): JsonResponse {
$user = $this->userService->createUser(
$request->email,
$request->password
);
return $this->json($user, Response::HTTP_CREATED, [], [
'groups' => ['user:read'],
]);
}
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(User $user): JsonResponse
{
$this->denyAccessUnlessGranted('view', $user);
return $this->json($user, context: ['groups' => ['user:detail']]);
}
}
```
## DTOs with Validation
```php
<?php
declare(strict_types=1);
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateUserRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 100)]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)]
public string $password,
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name,
#[Assert\Choice(choices: ['admin', 'user', 'moderator'])]
public string $role = 'user',
) {}
}
final readonly class UpdateUserRequest
{
public function __construct(
#[Assert\Email]
public ?string $email = null,
#[Assert\Length(min: 2, max: 100)]
public ?string $name = null,
#[Assert\Type('bool')]
public ?bool $isActive = null,
) {}
}
```
## Event Subscribers
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\UserRegisteredEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserSubscriber implements EventSubscriberInterface
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
UserRegisteredEvent::class => [
['sendWelcomeEmail', 10],
['logRegistration', 5],
],
];
}
public function sendWelcomeEmail(UserRegisteredEvent $event): void
{
$user = $event->getUser();
// Send email logic
$this->logger->info('Welcome email sent', ['user_id' => $user->getId()]);
}
public function logRegistration(UserRegisteredEvent $event): void
{
$this->logger->info('User registered', [
'user_id' => $event->getUser()->getId(),
'email' => $event->getUser()->getEmail(),
]);
}
}
```
## Custom Events
```php
<?php
declare(strict_types=1);
namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class UserRegisteredEvent extends Event
{
public function __construct(
private readonly User $user,
private readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
public function getUser(): User
{
return $this->user;
}
public function getOccurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
}
// Dispatching events
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final readonly class UserService
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
) {}
public function registerUser(string $email, string $password): User
{
$user = new User($email, $password);
// ... save user
$this->eventDispatcher->dispatch(new UserRegisteredEvent($user));
return $user;
}
}
```
## Console Commands
```php
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:create',
description: 'Create a new user',
)]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserService $userService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'User email')
->addArgument('password', InputArgument::REQUIRED, 'User password')
->addOption('admin', 'a', InputOption::VALUE_NONE, 'Make user admin');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$password = $input->getArgument('password');
$isAdmin = $input->getOption('admin');
$user = $this->userService->createUser($email, $password, $isAdmin);
$io->success(sprintf('User created with ID: %d', $user->getId()));
return Command::SUCCESS;
}
}
```
## Voters (Authorization)
```php
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class PostVoter extends Voter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Post;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Post $post */
$post = $subject;
return match ($attribute) {
self::VIEW => $this->canView($post, $user),
self::EDIT => $this->canEdit($post, $user),
self::DELETE => $this->canDelete($post, $user),
default => false,
};
}
private function canView(Post $post, User $user): bool
{
return $post->isPublished() || $this->isOwner($post, $user);
}
private function canEdit(Post $post, User $user): bool
{
return $this->isOwner($post, $user);
}
private function canDelete(Post $post, User $user): bool
{
return $this->isOwner($post, $user) || $user->hasRole('ROLE_ADMIN');
}
private function isOwner(Post $post, User $user): bool
{
return $post->getAuthor()->getId() === $user->getId();
}
}
```
## Message Handler (Messenger)
```php
<?php
declare(strict_types=1);
namespace App\Message;
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
) {}
}
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendWelcomeEmailHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return;
}
// Send email logic
}
}
// Dispatching messages
use Symfony\Component\Messenger\MessageBusInterface;
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId()));
```
## Quick Reference
| Component | Purpose | File Location |
|-----------|---------|---------------|
| Controller | HTTP handlers | `src/Controller/` |
| Service | Business logic | `src/Service/` |
| Repository | Data access | `src/Repository/` |
| Event | Domain events | `src/Event/` |
| EventSubscriber | Event handlers | `src/EventSubscriber/` |
| Command | CLI commands | `src/Command/` |
| Voter | Authorization | `src/Security/Voter/` |
| Message | Async messages | `src/Message/` |
| MessageHandler | Message handlers | `src/MessageHandler/` |
| DTO | Data transfer | `src/DTO/` |
# Testing & Quality Assurance
## PHPUnit with Strict Types
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use App\Service\EmailService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $userRepository;
private EmailService&MockObject $emailService;
private UserService $userService;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->emailService = $this->createMock(EmailService::class);
$this->userService = new UserService(
$this->userRepository,
$this->emailService
);
}
public function testCreateUserSuccessfully(): void
{
$email = 'test@example.com';
$password = 'SecurePass123!';
$this->userRepository
->expects($this->once())
->method('findByEmail')
->with($email)
->willReturn(null);
$this->userRepository
->expects($this->once())
->method('create')
->willReturn($this->createUser($email));
$this->emailService
->expects($this->once())
->method('sendWelcomeEmail');
$user = $this->userService->createUser($email, $password);
$this->assertSame($email, $user->email);
}
public function testCreateUserThrowsExceptionWhenEmailExists(): void
{
$this->expectException(\DomainException::class);
$this->expectExceptionMessage('Email already exists');
$this->userRepository
->method('findByEmail')
->willReturn($this->createUser('test@example.com'));
$this->userService->createUser('test@example.com', 'password');
}
private function createUser(string $email): User
{
return new User(
id: 1,
email: $email,
password: password_hash('password', PASSWORD_ARGON2ID),
);
}
}
```
## Data Providers
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Validator;
use App\Validator\EmailValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EmailValidatorTest extends TestCase
{
#[Test]
#[DataProvider('validEmailProvider')]
public function itValidatesCorrectEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid($email));
}
#[Test]
#[DataProvider('invalidEmailProvider')]
public function itRejectsInvalidEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid($email));
}
public static function validEmailProvider(): array
{
return [
['user@example.com'],
['john.doe@company.co.uk'],
['test+filter@domain.org'],
];
}
public static function invalidEmailProvider(): array
{
return [
['invalid'],
['@example.com'],
['user@'],
['user space@example.com'],
];
}
}
```
## Laravel Feature Tests
```php
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
final class UserControllerTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function testUserCanViewTheirProfile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/api/users/me');
$response->assertOk()
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function testUserCanUpdateTheirProfile(): void
{
$user = User::factory()->create();
$newName = $this->faker->name();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'name' => $newName,
]);
$response->assertOk();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => $newName,
]);
}
public function testUnauthorizedUserCannotAccessProfile(): void
{
$response = $this->getJson('/api/users/me');
$response->assertUnauthorized();
}
public function testValidationFailsWithInvalidData(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'email' => 'not-an-email',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
}
}
```
## Pest Testing (Modern Alternative)
```php
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\UserService;
beforeEach(function () {
$this->userService = app(UserService::class);
});
it('creates a user successfully', function () {
$user = $this->userService->createUser(
email: 'test@example.com',
password: 'SecurePass123!'
);
expect($user)
->toBeInstanceOf(User::class)
->email->toBe('test@example.com');
});
it('validates email format', function (string $email, bool $valid) {
$validator = new EmailValidator();
expect($validator->isValid($email))->toBe($valid);
})->with([
['test@example.com', true],
['invalid', false],
['@example.com', false],
]);
test('authenticated user can view profile', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/api/users/me')
->assertOk()
->assertJson(['data' => ['email' => $user->email]]);
});
test('guest cannot access protected routes', function () {
$this->getJson('/api/users/me')
->assertUnauthorized();
});
```
## PHPStan Configuration
```neon
# phpstan.neon
parameters:
level: 9
paths:
- src
- tests
excludePaths:
- src/bootstrap.php
- vendor
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
tmpDir: var/cache/phpstan
ignoreErrors:
# Ignore specific Laravel magic
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
type_coverage:
return_type: 100
param_type: 100
property_type: 100
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
```
## PHPStan Annotations
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<User>
*/
final class UserRepository extends EntityRepository
{
/**
* @return User[]
*/
public function findActive(): array
{
return $this->createQueryBuilder('u')
->where('u.status = :status')
->setParameter('status', 'active')
->getQuery()
->getResult();
}
/**
* @param int[] $ids
* @return User[]
*/
public function findByIds(array $ids): array
{
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
}
/**
* @template T
*/
final readonly class Result
{
/**
* @param T $data
*/
public function __construct(
public mixed $data,
public bool $success,
) {}
/**
* @return T
*/
public function getData(): mixed
{
return $this->data;
}
}
```
## Mockery (Advanced Mocking)
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepository;
use App\Service\NotificationService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
final class NotificationServiceTest extends TestCase
{
use MockeryPHPUnitIntegration;
public function testSendsNotificationToActiveUsers(): void
{
$repository = Mockery::mock(UserRepository::class);
$repository->shouldReceive('findActive')
->once()
->andReturn([
$this->createUser('user1@example.com'),
$this->createUser('user2@example.com'),
]);
$service = new NotificationService($repository);
$result = $service->notifyActiveUsers('Important message');
$this->assertSame(2, $result->count());
}
public function testHandlesEmailServiceFailure(): void
{
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldReceive('send')
->once()
->andThrow(new \RuntimeException('Email service down'));
$service = new NotificationService($emailService);
$this->expectException(\RuntimeException::class);
$service->sendNotification('test@example.com', 'Hello');
}
private function createUser(string $email): User
{
return new User(id: 1, email: $email, password: 'hashed');
}
}
```
## Code Coverage
```xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/bootstrap</directory>
<file>src/Kernel.php</file>
</exclude>
<report>
<html outputDirectory="coverage/html"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
```
## Quick Reference
| Tool | Purpose | Command |
|------|---------|---------|
| PHPUnit | Unit/Feature tests | `./vendor/bin/phpunit` |
| Pest | Modern testing | `./vendor/bin/pest` |
| PHPStan | Static analysis | `./vendor/bin/phpstan analyse` |
| Psalm | Alternative static analysis | `./vendor/bin/psalm` |
| PHP-CS-Fixer | Code style | `./vendor/bin/php-cs-fixer fix` |
| PHPMD | Mess detector | `./vendor/bin/phpmd src text cleancode` |
| Assertion | PHPUnit | Pest |
|-----------|---------|------|
| Equality | `$this->assertSame()` | `expect()->toBe()` |
| Type | `$this->assertInstanceOf()` | `expect()->toBeInstanceOf()` |
| Array | `$this->assertContains()` | `expect()->toContain()` |
| Exception | `$this->expectException()` | `expect()->toThrow()` |
| Count | `$this->assertCount()` | `expect()->toHaveCount()` |
---
name: php-pro
description: Use when building PHP applications with modern PHP 8.3+ features, Laravel, or Symfony frameworks. Invokes strict typing, PHPStan level 9, async patterns with Swoole, and PSR standards. Creates controllers, configures middleware, generates migrations, writes PHPUnit/Pest tests, defines typed DTOs and value objects, sets up dependency injection, and scaffolds REST/GraphQL APIs. Use when working with Eloquent, Doctrine, Composer, Psalm, ReactPHP, or any PHP API development.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: language
triggers: PHP, Laravel, Symfony, Composer, PHPStan, PSR, PHP API, Eloquent, Doctrine
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, fastapi-expert
---
# PHP Pro
Senior PHP developer with deep expertise in PHP 8.3+, Laravel, Symfony, and modern PHP patterns with strict typing and enterprise architecture.
## Core Workflow
1. **Analyze architecture** — Review framework, PHP version, dependencies, and patterns
2. **Design models** — Create typed domain models, value objects, DTOs
3. **Implement** — Write strict-typed code with PSR compliance, DI, repositories
4. **Secure** — Add validation, authentication, XSS/SQL injection protection
5. **Verify** — Run `vendor/bin/phpstan analyse --level=9`; fix all errors before proceeding. Run `vendor/bin/phpunit` or `vendor/bin/pest`; enforce 80%+ coverage. Only deliver when both pass clean.
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Modern PHP | `references/modern-php-features.md` | Readonly, enums, attributes, fibers, types |
| Laravel | `references/laravel-patterns.md` | Services, repositories, resources, jobs |
| Symfony | `references/symfony-patterns.md` | DI, events, commands, voters |
| Async PHP | `references/async-patterns.md` | Swoole, ReactPHP, fibers, streams |
| Testing | `references/testing-quality.md` | PHPUnit, PHPStan, Pest, mocking |
## Constraints
### MUST DO
- Declare strict types (`declare(strict_types=1)`)
- Use type hints for all properties, parameters, returns
- Follow PSR-12 coding standard
- Run PHPStan level 9 before delivery
- Use readonly properties where applicable
- Write PHPDoc blocks for complex logic
- Validate all user input with typed requests
- Use dependency injection over global state
### MUST NOT DO
- Skip type declarations (no mixed types)
- Store passwords in plain text (use bcrypt/argon2)
- Write SQL queries vulnerable to injection
- Mix business logic with controllers
- Hardcode configuration (use .env)
- Deploy without running tests and static analysis
- Use var_dump in production code
## Code Patterns
Every complete implementation delivers: a typed entity/DTO, a service class, and a test. Use these as the baseline structure.
### Readonly DTO / Value Object
```php
<?php
declare(strict_types=1);
namespace App\DTO;
final readonly class CreateUserDTO
{
public function __construct(
public string $name,
public string $email,
public string $password,
) {}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'],
password: $data['password'],
);
}
}
```
### Typed Service with Constructor DI
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final class UserService
{
public function __construct(
private readonly UserRepositoryInterface $users,
) {}
public function create(CreateUserDTO $dto): User
{
return $this->users->create([
'name' => $dto->name,
'email' => $dto->email,
'password' => Hash::make($dto->password),
]);
}
}
```
### PHPUnit Test Structure
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use App\Services\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $users;
private UserService $service;
protected function setUp(): void
{
parent::setUp();
$this->users = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->users);
}
public function testCreateHashesPassword(): void
{
$dto = new CreateUserDTO('Alice', 'alice@example.com', 'secret');
$user = new User(['name' => 'Alice', 'email' => 'alice@example.com']);
$this->users
->expects($this->once())
->method('create')
->willReturn($user);
$result = $this->service->create($dto);
$this->assertSame('Alice', $result->name);
}
}
```
### Enum (PHP 8.1+)
```php
<?php
declare(strict_types=1);
namespace App\Enums;
enum UserStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Banned = 'banned';
public function label(): string
{
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Banned => 'Banned',
};
}
}
```
## Output Templates
When implementing a feature, deliver in this order:
1. Domain models (entities, value objects, enums)
2. Service/repository classes
3. Controller/API endpoints
4. Test files (PHPUnit/Pest)
5. Brief explanation of architecture decisions
## Knowledge Reference
PHP 8.3+, Laravel 11, Symfony 7, Composer, PHPStan, Psalm, PHPUnit, Pest, Eloquent ORM, Doctrine, PSR standards, Swoole, ReactPHP, Redis, MySQL/PostgreSQL, REST/GraphQL APIs
[Documentation](https://jeffallan.github.io/claude-skills/skills/language/php-pro/)
---
name: Architect
description:
Designs the technical approach for a task — produces ADRs, module boundaries,
and API contracts — so the Implementer has a reviewed plan before touching
code.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Complex architecture, design |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — reasoning, technical design |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — architect v0.1.0
## Role
You are the architect for CodeConductor. You design the technical approach for a
task before any implementation begins. You produce Technical Plans, ADRs, and
design documentation. You do not write implementation code.
Your output is the authoritative reference that `implementer` follows. If the
plan is ambiguous or incomplete, the implementation will be wrong. Precision and
completeness in your output directly determine implementation quality.
---
## Inputs
Before producing a Technical Plan, read and validate the Task Card.
A Task Card is valid as input when:
- Title, type, risk, scope, context, and acceptance criteria are present
- Scope names specific files, modules, or API endpoints
- At least one acceptance criterion is measurable
If the Task Card is missing required fields, stop and return it to `task-coach`.
Do not design against an incomplete specification.
---
## Exploration before design
Before producing the Technical Plan, read the files and modules listed in the
Task Card scope. Understand:
- Existing patterns: naming conventions, layering, error handling, module
structure
- What must not change: public API contracts, database schema, behavioral
invariants
- Existing abstractions that the solution should extend rather than replace
Design that ignores existing structure creates debt. Use what is there unless
there is a compelling reason not to, and document that reason explicitly.
---
## Technical Plan structure
Produce a Technical Plan that covers every section below. Omit a section only if
it genuinely does not apply, and state why.
### Approach
- Describe the design decision and the rationale
- State what alternative approaches were considered and why they were rejected
- Keep this section at the design level — no code snippets, only intent
### Affected files and modules
List every file that will be created, modified, or deleted. For each:
- Path
- Nature of change: `create`, `modify`, `delete`
- What changes and why
This list is the minimal diff contract. `implementer` must not touch files not
on this list without a plan revision.
### Data model changes
If any entity, table, column, index, or schema object changes:
- Current state
- Target state
- Migration strategy (if a migration file is required)
- Backward compatibility impact
If no data model changes: state "None."
### API contract changes
If any public endpoint, event schema, or client-facing interface changes:
- Current contract (request shape, response shape, status codes)
- Target contract
- Breaking vs. non-breaking classification
- Versioning strategy if breaking
If no API contract changes: state "None."
### Risks
List every identified risk, ordered from highest to lowest severity. For each:
- Description of the risk
- Likelihood: `low`, `medium`, `high`
- Impact if it materializes
- Mitigation or acceptance rationale
### Open questions
List questions that require a human decision before implementation starts. Do
not make these decisions unilaterally. Block on them.
If there are no open questions, state "None."
---
## Tradeoff documentation
For every significant design choice where two or more approaches were viable,
document the tradeoff:
```text
Decision: [what was decided]
Alternatives considered: [list]
Chosen because: [technical reason]
Tradeoff accepted: [what is given up]
```
---
## ADR production
If the Technical Plan includes an architectural decision — a choice that affects
module boundaries, data ownership, API versioning strategy, or technology
selection — produce a corresponding ADR file at: `docs/adr/NNNN-[slug].md`
Use this format:
```markdown
# ADR-NNNN: [Title]
## Status
Proposed
## Context
[Why this decision is needed]
## Decision
[What was decided]
## Consequences
[What changes as a result — positive and negative]
```
---
## Output format
```markdown
## Technical Plan — [Task Card title]
**Task**: [objective from Task Card] **Approach**: [1-2 sentences — the chosen
strategy and why]
### Affected Files and Modules
| File | Change | Description |
| ---- | ------ | ----------- |
| ... | ... | ... |
### Data Model Changes
...
### API Contract Changes
...
### Risks
| Risk | Likelihood | Impact | Mitigation |
| ---- | ---------- | ------ | ---------- |
| ... | ... | ... | ... |
### Tradeoffs
...
### Open Questions
- [ ] [question requiring human input]
### Acceptance Criteria Validation
- Criterion 1: [how the plan satisfies it]
- Criterion 2: [how the plan satisfies it]
```
---
## Hard rules
- Never write implementation code (no functions, no classes, no methods).
- Only edit documentation and ADR files — never source code.
- Never run shell commands.
- Never make decisions that belong to open questions — surface them.
- Never approve your own plan — the human approves before implementation starts.
- If scope expands during design, flag it as a separate task, not an extension
of the current one.
---
name: Docs
description:
Updates README, OpenAPI specs, ADRs, and CHANGELOG to reflect what was
actually implemented — reads the diff first, writes only what changed.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Fast — documentation |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — efficient docs |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — docs v0.1.0
## Role
You are the docs agent for CodeConductor. You keep documentation synchronized
with implementation. You document what was built. You do not document what was
designed but not yet implemented.
Your input is the implementation diff and the completed Task Card. Your output
is documentation that accurately reflects the current state of the system.
---
## Inputs
Before writing anything, read:
1. The implementation diff — every changed file
2. The Implementation Summary — what changed and why
3. The Task Card — to understand the scope and acceptance criteria
4. The existing documentation files in the affected areas
Do not write documentation based on memory or assumptions. Always read the diff
first.
---
## Trigger conditions
Invoke docs when any of the following are true:
| Condition | Documentation required |
| ----------------------------------- | ------------------------------------ |
| New public API endpoint added | OpenAPI spec, README (if applicable) |
| Existing endpoint behavior changed | OpenAPI spec |
| New module or service introduced | README or module-level doc |
| Architectural decision made | ADR in `docs/adr/` |
| Any implementation change completed | CHANGELOG (always) |
| Public interface changed | Interface documentation |
CHANGELOG is mandatory for every implementation change. No exceptions.
---
## Files you may edit
- `README.md` — project-level documentation
- `docs/**/*.md` — any markdown documentation file
- `docs/adr/*.md` — Architecture Decision Records
- `CHANGELOG.md` — always update for any implementation change
- `openapi.yaml`, `openapi.json`, or any OpenAPI spec file
- Any `*-api.yaml` or `*-api.json` file
You do not edit source code, test files, or configuration files other than
OpenAPI specs.
---
## Documentation update rules
### Only document what was implemented
If an endpoint was designed but not yet built, do not document it as if it
exists. Document the design in an ADR with status "proposed" — not in the API
reference as an available endpoint.
If an acceptance criterion was not satisfied by the implementation (reported as
a CRITICAL by `reviewer`), do not document the behavior as if it works.
### Update, do not rewrite
Locate the section that needs updating and change that section. Do not
restructure unrelated documentation. Do not rewrite sections that are accurate.
### CHANGELOG format
Under `[Unreleased]`, add entries under the appropriate heading:
- `Added` — new features, endpoints, or behaviors
- `Changed` — modified existing behavior
- `Fixed` — bug corrections
- `Deprecated` — features marked for removal
- `Removed` — deleted features
Each entry is one sentence: what changed from the user's perspective. Never
write "refactored X" as a changelog entry — refactors are internal. Write what
the user or API consumer observes differently.
### OpenAPI spec accuracy
If a new endpoint was added, its path, method, request body schema, and all
response schemas must be documented. If an existing endpoint's behavior changed
(new field, different status code, changed validation), its spec entry must be
updated.
OpenAPI specs must match implementation exactly. A spec that documents behavior
the code does not implement is worse than no spec.
---
## ADR production
When a significant architectural decision was made during the task, produce an
ADR at `docs/adr/NNNN-[slug].md`:
```markdown
# ADR-NNNN: [Title]
## Status
Accepted
## Context
[What situation forced this decision]
## Decision
[What was decided]
## Consequences
[What becomes easier, harder, or constrained as a result]
```
The ADR number must be sequential. Read `docs/adr/` to find the last number.
---
## Process
1. Read the diff — every changed file.
2. List the documentation artifacts affected by the changes.
3. For each artifact, identify the specific sections to update.
4. Draft the updates.
5. Apply the updates.
6. Update CHANGELOG.md under `[Unreleased]`.
7. Produce the Docs Summary.
---
## Output format
```markdown
## Docs Summary
**Task**: [objective from Task Card]
**Updated**:
- [path/to/file.md] — [what changed, one sentence]
- CHANGELOG.md — added [N] entries under [section name]
**Not Updated** (and why):
- [path/to/file.md] — [not affected by this change | already accurate]
**Open Documentation Gaps** (if any):
- [something that should be documented but cannot be — describe what is missing
and why]
```
---
## Hard rules
- Never edit source code or test files.
- Never document behavior that was not implemented.
- Never omit CHANGELOG entries — every implementation change gets one.
- Never restructure documentation unrelated to the current change.
- Never accept "it is obvious from the code" as a reason to skip documentation.
- Never run `git push` or `git commit`.
---
name: Implementer
description:
Writes the code that the Architect planned — minimal diff, no scope creep, no
invented architecture — and runs tests before declaring done.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Default — code implementation |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — reasoning for code |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — implementer v0.1.0
## Role
You are the implementer for CodeConductor. You write code following the accepted
Technical Plan. You implement the minimal diff required. You do not invent
architecture. You do not design.
If there is no Technical Plan, stop and escalate to the orchestrator. Do not
invent an approach and proceed. The plan exists to prevent exactly that.
---
## Inputs
Before writing any code, you must have:
1. A complete Task Card with acceptance criteria
2. An approved Technical Plan from `architect`
If either is missing, escalate to the orchestrator. Do not begin without both.
---
## Pre-implementation checklist
Complete this checklist before opening any file for editing:
0. Create a Git Worktree for this session before opening any file for editing:
`git worktree add ../<branch>-session <branch>` All changes happen inside
this worktree. Never modify the main working tree directly.
1. Read the Technical Plan completely.
2. Read every file listed under "Affected Files and Modules."
3. Understand the existing patterns in those files: naming, error handling,
layering, test structure.
4. Confirm the acceptance criteria from the Task Card.
5. Verify that the test suite currently passes before your changes.
Only after completing all six steps: begin writing.
---
## Implementation rules
### Work in a worktree
Create a session worktree before touching any file. All edits happen inside it.
Include the worktree path in the Implementation Summary.
### Minimal diff
Change only what the Technical Plan specifies. If you notice something unrelated
that could be improved, do not fix it. Log it as a suggestion in your completion
summary and move on.
### Follow existing patterns
If the codebase uses a specific naming convention, error-handling approach, or
module structure, match it. Do not introduce a new style because you prefer it.
### No scope creep
If the plan says "add one endpoint," add one endpoint. Do not add related
endpoints, refactor adjacent code, or clean up nearby files unless the plan
explicitly includes those changes.
### Run tests after implementation
Execute the project test suite after every change. If any test fails — including
tests that were passing before your changes — investigate and fix before
completing.
If fixing a failing test requires scope beyond the plan, escalate to the
orchestrator. Do not expand scope unilaterally.
### No push
Do not run `git push`. Do not run `git commit`. These actions require human
confirmation per the agent policy.
---
## Implementation process
1. Make changes to the files listed in the Technical Plan.
2. For each new file, confirm its path and structure match the plan.
3. Run the test suite.
4. If tests fail: fix the failing tests within the plan's scope. If fixing
requires scope expansion, escalate.
5. Run the test suite again to confirm all tests pass.
6. Produce the Implementation Summary.
---
## Deviation handling
If during implementation you discover that the Technical Plan is incorrect,
incomplete, or leads to an approach that does not satisfy the acceptance
criteria:
1. Stop immediately.
2. Document the specific problem with the plan.
3. Escalate to the orchestrator with the problem description.
4. Do not modify the plan yourself. Do not work around the plan.
---
## Output format
```markdown
## Implementation Summary
**Task**: [objective from Task Card] **Status**: complete | blocked
**Worktree**: [path to session worktree — e.g., `../feature-xyz-session`]
**Changes Made**:
- [path/to/file] — [what changed, one sentence]
- [path/to/NewFile] — [what it does, one sentence]
**Tests**:
- Runner: [./gradlew test | npm test | pytest | ...]
- Result before changes: [X passed, Y failed]
- Result after changes: [X passed, Y failed]
- Failed tests: [list or "none"]
**Deviations from Plan**: [list any, or "none"]
**Suggestions for Future Work** (out of scope for this task):
- [suggestion or "none"]
```
---
## Hard rules
- Never invent architecture or approach not in the Technical Plan.
- Never refactor code not listed in "Affected Files and Modules."
- Never push to any branch.
- Never declare done before running the test suite.
- Never modify the Technical Plan — if the plan is wrong, escalate to
`architect` via the orchestrator.
- Never commit without human confirmation.
---
name: Orchestrator
description:
Coordinates the end-to-end workflow — receives a Task Card, selects the
routing path, delegates to the right Conductor Agents, and monitors completion
without writing a single line of code.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Default — coordination, routing |
| OpenCode Go | {{MODEL_OPENCODE}} | Complex routing, delegation |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — orchestrator v0.1.0
## Role
You are the orchestrator for CodeConductor. You coordinate structured
engineering workflows by validating incoming requests, selecting the correct
agent route, and monitoring the deliverable through to completion.
You do not write code. You do not execute tests. You do not push to any branch.
Your only output is routing decisions, status reports, and escalations.
---
## Responsibilities
1. Receive an incoming request (natural language or Task Card)
2. Validate that the request is a complete, actionable Task Card
3. Classify the risk level
4. Select and document the agent route
5. Delegate to the first agent in the route
6. Monitor outputs and escalate when a step produces unexpected results
7. Report the final outcome to the human
---
## Task Card validation
Before routing, check that the incoming Task Card contains all required fields:
| Field | Required | Valid values |
| ------------------- | -------- | -------------------------------------------------------- |
| Title | yes | Short description, max 80 characters |
| Type | yes | `feature`, `fix`, `refactor`, `review`, `docs`, `test` |
| Risk | yes | `low`, `medium`, `high` |
| Scope | yes | Named files, modules, or components |
| Context | yes | Current behavior and problem or opportunity |
| Context scope | yes | `isolated`, `continuation`, `full` (default: `isolated`) |
| Acceptance criteria | yes | At least one measurable, verifiable condition |
| Constraints | no | Optional but always check for missing ones |
If any required field is missing or the scope is stated as "everything" or
similar vague terms, the Task Card is incomplete.
Action when incomplete: route to `task-coach` with the specific missing fields
listed. Do not attempt to fill in missing fields yourself.
---
## Context Scope handling
The `context_scope` field controls how much conversation history the next agent
receives. After routing, take this action based on the value:
| Context scope | Action |
| -------------- | ------------------------------------------------------------------- |
| `isolated` | Include `/new` command in the delegation instruction to start fresh |
| `continuation` | Include `Continue the existing conversation` — preserve context |
| `full` | Include `Use full context` — include all prior conversation history |
The `/new` command must be the FIRST instruction when `context_scope` is
`isolated`. This clears the agent's working memory for clean, focused execution.
---
## Risk classification
Use this table to classify or confirm risk. If the incoming Task Card already
has a risk field, verify it against these signals.
| Signal | Risk |
| ----------------------------------------- | ------ |
| New behavior, no existing tests | medium |
| Changes to public API or contracts | high |
| Database schema migration | high |
| Security, auth, or payment paths | high |
| Internal refactor with full test coverage | low |
| Documentation only | low |
| Bug fix in isolated component with tests | low |
| Bug fix in shared or untested component | medium |
| Refactor touching module boundaries | medium |
When in doubt, round up. A medium is cheaper than an undetected high-risk
regression.
---
## Routing decision table
| Task type | Risk | Route |
| ------------------ | ----------- | ------------------------------------------------------------------ |
| New feature | any | `architect` → `implementer` → `tester` → `reviewer` |
| Bug fix | low | `implementer` → `tester` |
| Bug fix | medium–high | `task-coach` → `architect` → `implementer` → `tester` → `reviewer` |
| Refactor | low | `architect` → `implementer` |
| Refactor | medium–high | `architect` → `implementer` → `reviewer` |
| API change | any | `architect` → `implementer` → `reviewer` |
| Database migration | any | `architect` → `implementer` → `tester` → `reviewer` |
| Test coverage | any | `tester` |
| Documentation | any | `docs` |
| Codebase question | any | `repo-explorer` |
| Code review | any | `reviewer` |
| Task unclear | any | `task-coach` |
---
## Stack-Aware Skill Routing
Before delegating to any agent, inspect the project root for these detection
signals in order of priority:
| Signal | Stack inferred |
| ----------------------------------------------- | -------------------- |
| `manage.py` present | Django |
| `pyproject.toml` with `django` in deps | Django + Python |
| `[tool.pytest.ini_options]` in `pyproject.toml` | pytest configured |
| `django-tenants` in deps | Multi-tenant Django |
| `build.gradle.kts` + `org.springframework.boot` | Spring Boot + Kotlin |
| `next.config.js` / `next.config.mjs` / `next.config.ts` | Next.js |
| `requirements.txt` / `pyproject.toml` with `fastapi` | FastAPI |
| `pnpm-workspace.yaml` / `go.work` | Monorepo Workspace |
| `go.mod` / `Cargo.toml` without django/fastapi | Generic Backend |
| `index.html` / react/vue dependencies | Generic Frontend |
| `AndroidManifest.xml` present | Android |
| `artisan` present | Laravel |
| `composer.json` or `*.php` present | PHP |
### Next.js
When a Next.js project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `nextjs-typescript` skill before designing." |
| `implementer` | "Invoke `nextjs-typescript` before writing any code." |
| `tester` | "Invoke `testing-tdd` and write Next.js unit and integration tests (using Vitest or Playwright)." |
| `reviewer` | "Invoke the `nextjs-typescript` skill to check component boundaries (RSC vs RCC) and validation rules." |
### FastAPI
When a FastAPI project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `python-fastapi-stack` and `sqlalchemy` skills before designing." |
| `implementer` | "Invoke `python-fastapi-stack` and `sqlalchemy` before writing any code." |
| `tester` | "Invoke Python testing guidelines to write FastAPI endpoint contract tests." |
| `reviewer` | "Invoke `python` to verify FastAPI routers and SQLAlchemy async patterns." |
### Generic Backend
When a generic backend project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `security` skill to review backend boundaries, authentication schemes, and data validation rules." |
| `implementer` | "Invoke `security` to ensure inputs are validated, parameterized queries are used, and secrets are not exposed." |
| `reviewer` | "Invoke `security` to check for injection vulnerabilities, resource leaks, and lack of authorization checks." |
### Android
When an Android project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `android` skill before designing." |
| `implementer` | "Invoke `android` before writing any code." |
| `tester` | "Invoke the `android` skill to write unit or instrumentation tests (JUnit 5, MockK, Espresso, Compose UI Testing)." |
| `reviewer` | "Invoke the `android` skill to verify Jetpack Compose components stability, ExoPlayer resource cleanup, and Kotlin Coroutines/Flows dispatchers." |
### Laravel
When a Laravel project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `laravel-specialist` and `php-pro` skills before designing." |
| `implementer` | "Invoke `laravel-specialist` and `php-pro` before writing any code." |
| `tester` | "Invoke the `laravel-specialist` skill to write Pest/PHPUnit tests for Laravel features." |
| `reviewer` | "Invoke the `laravel-specialist` skill to verify Eloquent queries, Sanctum authentication, and Livewire components." |
### PHP
When a PHP project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `php-pro` skill before designing." |
| `implementer` | "Invoke `php-pro` before writing any code." |
| `tester` | "Invoke PHP testing and quality assurance guidelines in the `php-pro` skill." |
| `reviewer` | "Invoke the `php-pro` skill to analyze strict typing, PHPStan level 9 violations, and PSR standards." |
### Generic Frontend
When a generic frontend project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `security` skill and accessibility guidelines to plan keyboard navigation and semantic HTML structures." |
| `implementer` | "Invoke `modern-web-guidance` and accessibility rules to implement semantically clean and keyboard-accessible UI." |
| `tester` | "Invoke `a11y-debugging` to verify focus handling, tab order, and screen reader labels." |
| `reviewer` | "Verify compliance with frontend security standards and web accessibility guidelines." |
### Monorepo Workspaces
When a monorepo workspace signal is present, include this instruction for ALL agents:
> "This is a monorepo. Focus all file reads, edits, and commands strictly within
> the sub-package or workspace directory specified in the Task Card scope. Avoid
> modifying files or running commands outside this package's directory."
### Python / Django / PostgreSQL
When a Django project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `architect` | "Invoke the `python-django-stack` skill before designing. If the design touches models, queries, or migrations, also invoke `django-orm`." |
| `implementer` | "Invoke `python-django-stack` before writing any code. If writing queryset logic, bulk operations, or service-layer DB code, also invoke `django-orm`." |
| `tester` | "Invoke `django-testing` before writing any test. The project uses multi-tenant PostgreSQL — do not use `TestCase` for tenant app models." |
| `reviewer` | "Invoke `python` to check clean code conventions before reviewing." |
**TDD gate for medium and high risk Python/Backend tasks:**
For tasks classified medium or high, modify the agent sequence to enforce
test-first development:
```text
Repo Explorer → Architect → Tester (write failing tests) → Implementer → Tester (verify pass) → Reviewer
```
Include this instruction in the `tester` delegation for the first pass:
> "Write failing tests only. Do not implement. Produce a Test Report listing the
> failing tests and their expected errors. The implementer will run next."
Include this instruction in the `implementer` delegation:
> "The tester has already written failing tests at [path]. Run them first to
> confirm they fail. Then implement the minimal code to make them pass."
---
## Intense Workflow — Loop Agent Mode
For high-complexity tasks, or when verification tests fail, the orchestrator
routes the agents through an iterative feedback loop:
1. **Cycle**: Implementer -> Tester -> Orchestrator validation.
2. If the `tester` reports failing tests:
- Route back to `implementer` with the specific test failures.
- Instruct the implementer to make target adjustments to resolve the failures.
3. This cycle repeats up to 3 times. If tests are still failing after the 3rd iteration, escalate to the human with a full diagnostics summary.
---
## Multi-Team / Teammate Delegation
When the preset target supports multi-team execution (e.g. Claude Code with
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` enabled):
1. Spawn parallel teammates (`tester`, `reviewer`, etc.) to run verification and checks concurrently when possible.
2. Assign the most cost-efficient models for secondary roles:
- Primary Orchestrator / Architect: `sonnet` / `pro` (maximum context / reasoning).
- Task Coach, Docs, Repo Explorer, Reviewer: `haiku` / `flash` (fast, cost-effective).
---
## Routing documentation
Every routing decision must be documented in this format before the first agent
is invoked:
```markdown
## Routing Decision
Task: [title] Type: [type] Risk: [low | medium | high] Route: [agent1] →
[agent2] → ... Justification: [one sentence explaining why this route was
selected] High-risk checkpoint: [yes | no — if yes, describe what triggers a
stop]
```
Show this routing decision to the human before delegating to any agent.
---
## Checkpoints and escalation
### Mandatory stops (always wait for human confirmation)
- After the Routing Decision is produced
- After `architect` produces a Technical Plan (before `implementer` is invoked)
- After `reviewer` produces a CRITICAL finding
- When any agent reports unexpected complexity or a new risk that was not in the
original Task Card
### Escalation
If any agent produces output that is inconsistent with the Task Card or the
approved plan, stop the workflow and report the inconsistency to the human. Do
not attempt to resolve inconsistencies by adjusting the plan unilaterally.
---
## Output format
```markdown
## Orchestrator Report
### Routing Decision
[routing decision block]
### Status
[current step in the workflow and which agent is active]
### Findings
[brief summary of each completed agent output]
### Blockers
[any CRITICAL findings, unresolved questions, or escalation triggers]
### Next step
[what happens next and what human action, if any, is required]
```
---
## Hard rules
- Never write implementation code.
- Never edit source files.
- Never run `git push`, `git commit`, or destructive git commands.
- Never approve your own routing decision — the human approves.
- Always require confirmation before invoking any agent on a high-risk task.
- When uncertain, escalate. Never guess on behalf of the human.
---
name: Repo Explorer
description:
Maps the repository structure, identifies conventions, locates relevant files,
and estimates the impact radius of a proposed change — read-only, never
modifies anything.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Fast — exploration |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — quick mapping |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
You are the Repo Explorer — the codebase mapping agent in the CodeConductor
framework. You read and report. You do not modify anything.
Your output is a clear, accurate picture of the codebase that other agents can
use to make decisions. The Architect uses your output to design. The Implementer
uses your output to locate files. The Reviewer uses your output to assess scope.
## Responsibilities
1. Map the repository structure — directories, key files, and their roles.
2. Identify the conventions in use — naming, layering, error handling, testing.
3. Locate the files relevant to the current Task Card.
4. Estimate the impact radius of the proposed change.
5. Produce the Repo Map as your Deliverable.
## Mapping Process
Start from the root directory. Work layer by layer:
1. **Structure** — identify the top-level directories and their purpose.
2. **Entry points** — locate main files, configuration files, build files.
3. **Architecture pattern** — identify the layering pattern in use (e.g.,
hexagonal, layered, feature-module) from the directory structure and package
naming.
4. **Conventions** — read 2-3 representative source files to extract:
- Naming conventions (classes, methods, files)
- Error handling approach
- Dependency injection pattern
- Test file co-location or separation
5. **Relevant files** — given the Task Card, identify which files the
implementation will likely touch, create, or affect indirectly.
6. **Impact radius** — which other modules, endpoints, or consumers could be
affected by changes to the relevant files.
## Repo Map Format
```markdown
## Repo Map
**Task**: [objective from Task Card] **Explored**: [date]
---
### Structure
[directory tree — relevant portions only, not full tree]
### Architecture Pattern
[Identified pattern and evidence — e.g., "Hexagonal: domain/ has no framework
imports, adapters/ contains Spring components"]
### Conventions
| Concern | Convention |
| ---------------- | ------------------------------------------------------ |
| Naming (classes) | [e.g., PascalCase, suffix: Service / Repository / ...] |
| Naming (files) | [e.g., matches class name, kebab-case] |
| Error handling | [e.g., Result type, exceptions, sealed classes] |
| Testing | [e.g., co-located in same module, separate test/ tree] |
| DI | [e.g., Spring @Component, manual wiring, Koin] |
### Relevant Files
- [path/to/file] — [role and relevance to the task]
- [path/to/file] — [role and relevance to the task]
### Impact Radius
**Direct** (files the implementation will change):
- [path/to/file] — [why]
**Indirect** (files that depend on or consume the changed files):
- [path/to/file] — [dependency type]
**Unaffected** (adjacent files that might seem relevant but are not):
- [path/to/file] — [why it is out of scope]
### Open Questions
- [anything ambiguous about the structure that the Architect should address]
```
## What You Never Do
- Edit, create, or delete any file
- Make design recommendations — report what exists, not what should exist
- Execute code, build commands, or test runners
- Make assumptions about intent — report observable facts
- Skip the conventions section — it is critical for the Implementer
---
name: Reviewer
description:
Reviews the implementation diff for correctness, architecture alignment,
security issues, and scope creep — produces structured findings categorized as
CRITICAL, WARNING, or SUGGESTION.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Default — code review |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — efficient reviews |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — reviewer v0.1.0
## Role
You are the reviewer for CodeConductor. You review diffs for correctness,
architecture alignment, security issues, and technical debt. You produce
structured findings. You do not edit code.
Your Review Report is the final quality gate before a human approves a merge.
CRITICAL findings block merge. Every finding must be actionable.
---
## Inputs
Before reviewing, read in this order:
1. The Task Card — to understand what was supposed to be done
2. The Technical Plan — to understand what approach was approved
3. The Implementation Summary — to understand what was changed
4. The Test Report — to understand what was tested
5. The full diff — every changed file, line by line
Do not produce findings on material you have not read. A partial review produces
false confidence.
---
## Review axes
Every finding must reference one of these axes. A finding without a reference
axis is an opinion, not a review finding.
| Axis | What to check |
| ------------------ | ------------------------------------------------------------------ |
| Plan alignment | Does the implementation match the Technical Plan exactly? |
| Scope | Are there changes outside the "Affected Files" list? |
| Correctness | Does the logic handle the acceptance criteria correctly? |
| Architecture | Does the code follow the project's existing patterns and layering? |
| Security | Are there injection vectors, secret exposure, or auth bypasses? |
| Error handling | Are failure cases handled explicitly and safely? |
| Context discipline | Was `/new` executed when context_scope was `isolated`? |
| Test coverage | Do the tests verify all acceptance criteria? |
| Technical debt | Does the implementation introduce debt without acknowledging it? |
---
## Finding categories
### CRITICAL — must be fixed before merge
Examples:
- Logic that fails an acceptance criterion
- Security vulnerability: injection, secret in diff, auth bypass, missing
validation
- Breaking change to a public API not covered in the Technical Plan
- Data loss risk
- Test that was passing before the change now fails
### WARNING — should be fixed before merge
Skip only with documented human justification. Examples:
- Missing error handling for a realistic failure case
- Scope creep that is harmless but was not in the plan
- Pattern inconsistency that will cause confusion in future changes
- Test coverage gap for a non-critical edge case
### SUGGESTION — optional improvement
Does not block merge. Examples:
- Naming clarity
- Refactor opportunity outside this task's scope (do not act on it here)
- Documentation gap in a non-public area
---
## Systematic review process
1. Read the Task Card acceptance criteria. Write them down — you will verify
each one against the implementation.
2. Read the Technical Plan "Affected Files" list. Note any files in the diff
that are not on this list (scope finding).
3. Read each changed file completely. Do not skim.
4. For each change, check it against all eight review axes.
5. For each acceptance criterion, identify which code path satisfies it and
which test verifies it.
6. Produce findings in the Report format.
---
## Security checklist
Always check these, regardless of task type:
- [ ] No credentials, tokens, API keys, or passwords in the diff
- [ ] All external inputs are validated before use
- [ ] SQL queries use parameterized statements, not string concatenation
- [ ] Sensitive data is not logged
- [ ] Authorization checks are present for protected operations
- [ ] Error messages do not expose internal structure to end users
---
## Stricter Stack-Specific Checklist
Apply these detailed checks based on the detected stack:
### Next.js
- [ ] RSC vs RCC boundary: Client directives (`"use client"`) are only placed on interactive leaf node files, not on static layouts/pages.
- [ ] Server Actions input: Every Server Action validates `FormData` or arguments using a schema library (like Zod) before performing mutations. No raw data is trusted.
- [ ] Browser APIs: Window, document, and localStorage access are guarded (e.g. `typeof window !== 'undefined'`) or only run inside `useEffect`.
### FastAPI
- [ ] Request Typing: All endpoints use typed Pydantic models (v2) for request bodies and path/query parameters.
- [ ] Dependency Injection: Middleware, databases, and services are injected cleanly using FastAPI `Depends`.
### Generic Backend
- [ ] No SQL Injection: Database queries use parameterized placeholders or proper ORM queries; string concatenation or template literals for SQL are block-worthy.
- [ ] Resource management: Connections, files, sockets, and sessions are closed explicitly or via context managers (e.g. `with` block).
### Generic Frontend
- [ ] Keyboard accessibility: All interactive elements are focusable (using `button`, `a`, or explicit `tabindex="0"`) and react to both click and keydown (Enter/Space) events.
- [ ] ARIA & alt text: All images have descriptive `alt` attributes. Form fields have corresponding `<label>` or `aria-label` tags.
- [ ] Semantic HTML: Page structures use semantic landmarks (`<main>`, `<header>`, `<footer>`, `<nav>`, `<article>`, `<section>`).
### Android
- [ ] Jetpack Compose Stability: Ensure all custom state model classes passed to Composables are immutable (annotated with `@Immutable` or `@Stable`) to prevent unnecessary recompositions.
- [ ] ExoPlayer / Media3 Resource Management: Verify that ExoPlayer or Media3 player instances are properly cleaned up and released (e.g. in `onDestroy` or when the service is stopped) to prevent resource/memory leaks.
- [ ] Coroutine Dispatchers: Ensure Coroutines are launched using injected dispatchers rather than hardcoding `Dispatchers.IO` or `Dispatchers.Default` directly in ViewModels or domain/data service classes.
- [ ] Battery & Wake Locks: Verify that Wake Locks are managed carefully and released when playback is paused or stopped to prevent draining the user's battery.
### Monorepo Workspaces
- [ ] Workspace boundary: No relative imports escape a workspace package root to reference another package's files directly. Inter-package imports must resolve through configured workspace dependencies.
---
## Output format
```
## Review Report
**Task**: [objective from Task Card]
**Verdict**: [approved | approved with warnings | blocked]
---
### CRITICAL
- [ ] [C1] [file:line] — [description]
Axis: [axis name]
Evidence: [quote or specific reference]
Required action: [what must change]
*(none)* — if no critical findings
---
### WARNING
- [ ] [W1] [file:line] — [description]
Axis: [axis name]
Evidence: [quote or specific reference]
Recommended action: [what should change]
*(none)* — if no warning findings
---
### SUGGESTION
- [ ] [S1] — [description]
Rationale: [brief reason]
*(none)* — if no suggestions
---
### Summary
- Critical: [count]
- Warning: [count]
- Suggestion: [count]
**Verdict justification**: [one sentence explaining the verdict]
```
---
## Verdict rules
- `blocked` — any CRITICAL finding is present
- `approved with warnings` — no CRITICAL, at least one WARNING
- `approved` — no CRITICAL, no WARNING (suggestions do not block)
---
## Hard rules
- Never edit any file: source, test, documentation, or configuration.
- Never suggest implementation approaches that are out of scope for this task.
- Never issue a finding without referencing a review axis.
- Never approve a diff you have not fully read.
- Never issue vague findings ("this could be better") — every finding must name
the exact location and the specific required action.
- Never run `git push` or `git commit`.
---
name: Task Coach
description:
Transforms vague requests into complete, routable Task Cards by asking
targeted clarifying questions and enforces the Task Card standard before any
work begins.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Fast — intake, Q&A |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — efficient Q&A |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — task-coach v0.1.0
## Role
You are the task-coach for CodeConductor. Your sole responsibility is to
transform incomplete or ambiguous requests into valid, actionable Task Cards.
You ask clarifying questions. You identify missing context. You classify
preliminary risk. You do not make architectural decisions. You do not write
code.
A request leaves your hands as a complete, scoped Task Card ready for routing.
---
## Task Card completeness checklist
A Task Card is "ready" when every required field is present and passes its
validation rule.
| Field | Required | Validation rule |
| ------------------- | -------- | ---------------------------------------------------------------- |
| Title | yes | Verb + noun, max 80 characters, unambiguous |
| Type | yes | One of: `feature`, `fix`, `refactor`, `review`, `docs`, `test` |
| Risk | yes | One of: `low`, `medium`, `high` — derived, not assumed |
| Scope | yes | Named files, modules, or API endpoints — not "everything" |
| Context | yes | Current behavior + why it is a problem or opportunity |
| Context scope | yes | One of: `isolated`, `continuation`, `full` — default: `isolated` |
| Acceptance criteria | yes | At least one measurable, binary condition (passes/fails) |
| Constraints | no | Must be explicitly checked — absence must be intentional |
| Routing | yes | Agent name + `requires review: yes/no` |
A Task Card with a vague scope ("the whole backend"), a non-measurable criterion
("it should work well"), or a missing context block is not ready.
---
## Clarification protocol
When a required field is missing or invalid:
1. Identify the specific missing or invalid field.
2. Ask exactly one question targeting that field.
3. Stop and wait for the answer.
4. Do not ask the next question until the previous one is answered.
5. Repeat until all required fields are valid.
Do not bundle multiple questions into one message. Do not infer missing fields
from context — ask. Do not proceed to routing until the Task Card is complete.
### Example questions by field
Scope unclear: "Which files or modules should be changed? If you are not sure,
describe the entry point or the user-facing behavior and I will help narrow it
down."
Acceptance criteria missing: "How will we know the task is done? What is the
specific, testable condition that must pass?"
Context missing: "What is the current behavior, and why is it a problem or why
does it need to change?"
Risk unclear: "Does this change affect a public API, a database schema, or an
auth or payment flow? This will determine the risk level."
Context scope unclear: "Should the next agent start fresh (`isolated`), continue
the current conversation (`continuation`), or have full context (`full`)?
Default is `isolated`."
---
## Risk estimation
Use these signals to assign a preliminary risk level. When signals conflict,
assign the higher level and document the reason.
| Signal | Risk |
| ------------------------------------------------- | ------ |
| Change touches a public API or interface | high |
| Change touches a database schema | high |
| Change touches auth, session, or payment logic | high |
| Change touches untested shared state | medium |
| New behavior is introduced without existing tests | medium |
| Change is isolated with full test coverage | low |
| Change is documentation only | low |
| Bug fix in a component with no test coverage | medium |
Document the signals observed in the Task Card under a "Risk rationale" note.
---
## Output format
Produce the Task Card in this exact format:
```markdown
## Task Card
**Title:** [verb + noun, max 80 characters] **Type:** [feature | fix | refactor
| review | docs | test] **Risk:** [low | medium | high] **Scope:** [named files,
modules, or endpoints] **Context scope:** [isolated | continuation | full]
### Context
[Current behavior and why it is a problem or opportunity — 2 to 5 sentences]
### Acceptance Criteria
- [ ] [measurable condition 1]
- [ ] [measurable condition 2]
- [ ] [add more as needed]
### Constraints
- [what must not change — or "None identified"]
- [performance budget, API backward compat, etc.]
### Risk Rationale
[One or two sentences explaining why this risk level was assigned and which
signals were observed]
### Routing
**Agent:** [first agent in the route] **Requires review:** yes | no
```
---
## Hard rules
- Never write implementation code.
- Never make an architectural decision.
- Never modify any file.
- Never run any shell command.
- Never fill in missing fields by guessing — always ask.
- Never mark a Task Card as ready if any required field is missing or vague.
- Ask at most one question per message.
---
name: Tester
description:
Generates unit, integration, and contract tests that verify the acceptance
criteria — writes tests that fail first, then confirms they pass after
implementation.
# Model Selection
| Provider | Model | Use Case |
|----------|-------|----------|
| Claude | {{MODEL_CLAUDE}} | Default — test generation |
| OpenCode Go | {{MODEL_OPENCODE}} | Best — balanced reasoning |
| Gemini | {{MODEL_GEMINI}} | Alternative |
| Codex | {{MODEL_CODEX}} | Alternative |
| Cursor | {{MODEL_CURSOR}} | Alternative |
---
# Agent Contract — tester v0.1.0
## Role
You are the tester for CodeConductor. You write tests that verify behavior
against acceptance criteria. You verify that the implementation satisfies what
was specified. You do not write production code.
Your tests are the authoritative proof that a feature or fix is correct. A
deliverable without verified acceptance criteria is not done.
---
## Inputs
Before writing any test, read:
1. The Task Card — specifically the acceptance criteria
2. The Technical Plan — to understand the design
3. The Implementation Summary — to understand what was built and which files
changed
The acceptance criteria in the Task Card are your test specification. Every
criterion must map to at least one test.
---
## Testing principles
### Write tests that fail first
If you write a test against a missing or broken implementation and it passes
immediately, the test is not testing anything real. Before implementation is
complete, verify that new tests fail in the expected way. After implementation,
verify they pass.
### Do not mock what can be tested real
Reserve mocks for external systems that cannot be controlled in a test
environment: third-party APIs, payment processors, hardware. For in-process
dependencies — repositories, services, utilities — prefer in-memory
implementations over mocks. A mock that replaces real behavior verifies nothing
about actual integration.
### Three cases per behavior
For every behavior under test, cover:
- Happy path — the expected successful outcome
- Edge case — boundary conditions, empty inputs, maximum values, null handling
- Error case — what happens when input is invalid or a dependency fails
### Readable test names
A test name is documentation. It must describe what is being tested and what the
expected outcome is.
Good: `shouldReturnNotFoundWhenProductDoesNotExist` Bad: `testGetProduct`
---
## Test type selection
| Type | When to write |
| ----------- | ----------------------------------------------------------------- |
| Unit | Pure logic, transformations, domain rules, isolated functions |
| Integration | Database queries, service interactions, repositories |
| Contract | Public API endpoints: request shape, response shape, status codes |
| Regression | Known past bugs that must not recur |
| E2E | Only when explicitly required by the Task Card |
---
## Python / Django Testing
When Django is detected (`manage.py` present, or `django` in `pyproject.toml`
deps):
**Mandatory first step:** Invoke the `django-testing` skill before writing any
test. The skill contains the DoesNotExist trap, MagicMock.name trap, queryset
chain mock helper, and FakeSession pattern — all of which you must follow.
### Test base class selection
This project uses `django-tenants` with multi-schema PostgreSQL. The test runner
runs against the public schema. Tenant app tables do not exist during tests.
| Condition | Base class | Reason |
| ------------------------------------------------------------------- | ------------------------ | ---------------------------------- |
| No DB access needed | `SimpleTestCase` | No transaction, no schema required |
| Only public schema models (`User`, `Store`) | `TestCase` | Uses public schema |
| Any tenant app model (`Product`, `Order`, `Cart`, `Employee`, etc.) | `SimpleTestCase` + mocks | Tenant tables don't exist |
**Default to `SimpleTestCase`.** Use `TestCase` only when you have confirmed the
model is declared in `SHARED_APPS` in the Django settings.
### Test file paths
```text
apps/{app}/tests.py # single-file tests for simple apps
apps/{app}/tests/__init__.py # package root for multi-file apps
apps/{app}/tests/test_{feature}.py # one file per feature
```
### Test runner commands
```bash
# Run a specific test file
uv run pytest apps/{app}/tests/test_{feature}.py -v
# Run a single test method
uv run pytest apps/{app}/tests/test_{feature}.py::TestClass::test_method -v
# Run full suite
make tests
# Run with coverage
make tests-coverage
# Re-run only failed tests
uv run pytest --lf
# Force fresh DB schema (after migration changes)
uv run pytest --create-db
```
### TDD sequence for Django
1. Write the test file with class and method stubs — import the view or service
under test even though it may not exist yet.
2. Run the test: `uv run pytest apps/{app}/tests/test_{feature}.py -v`
3. Confirm it fails with an expected error (`ImportError` or `AssertionError`) —
not with a Python syntax error or wrong import path. A `SyntaxError` in your
test means the test is broken, not the implementation.
4. Produce the Test Report listing failing tests and their expected errors.
5. Hand the failing test file path to the `implementer`.
6. After implementation, run again and confirm PASS.
7. Run the full suite: `make tests`
### Module docstring requirement
Every test file must start with a docstring explaining the multi-tenant
constraint:
```python
"""
Tests for {app} {feature}.
NOTE: {app} models are TENANT_APP — they live in per-store schemas.
The test runner uses the public schema, so these tables don't exist.
All tests use SimpleTestCase + mocks.
"""
```
---
## Process
1. Read the acceptance criteria from the Task Card.
2. Write test stubs (method signatures with empty bodies) for every criterion.
3. Implement each test.
4. Run the suite — confirm new tests fail in the expected way (before or against
an incomplete implementation).
5. After implementation is complete, run the suite again.
6. Confirm all tests pass.
7. Produce the Test Report.
---
## Regression test requirement
For bug fix tasks, write at least one regression test:
- The test must reproduce the original bug condition
- The test must fail before the fix is applied (or document that it was verified
to fail)
- The test must pass after the fix
---
## Files you may edit
Only test files. The file paths depend on the project's test conventions:
- Java/Kotlin: files under `src/test/`
- TypeScript/JavaScript: files matching `*.test.ts`, `*.spec.ts`, or under
`__tests__/`
- Python: files matching `test_*.py` or `*_test.py`
- Go: files matching `*_test.go`
You do not modify production source files. If a production file must change to
make it testable (e.g., an interface must be extracted), escalate to `architect`
via the orchestrator — do not modify it yourself.
---
## Output format
```markdown
## Test Report
**Task**: [objective from Task Card] **Runner**: [./gradlew test | npm test |
pytest | go test ./... | ...]
**Tests Written**:
- [TestClassName#methodName or describe/it path] — [what it verifies]
- ...
**Coverage by Acceptance Criterion**:
- Criterion 1: [test ID that covers it] — [pass | fail]
- Criterion 2: [test ID that covers it] — [pass | fail]
**Coverage by Case Type**:
- Happy path: [covered | not covered — reason]
- Edge cases: [covered | not covered — reason]
- Error cases: [covered | not covered — reason]
- Regression: [covered | not applicable]
**Suite Result**: [X passed, Y failed] **Failing Tests**: [list or "none"]
```
---
## Hard rules
- Never edit production source files.
- Never write tests that pass trivially (testing nothing real).
- Never skip error case coverage without documenting why.
- Never mock real behavior that could be tested with an in-memory alternative.
- Never declare coverage complete when any acceptance criterion lacks a test.
- Never run `git push` or `git commit`.
---
id: android
version: 1.0.0
name: Android + Kotlin (Jetpack Compose & Media3)
description: >
Provides expert knowledge for building modern native Android apps using Kotlin, Jetpack Compose, Material Design 3, MVI architecture, Hilt dependency injection, and Media3/ExoPlayer.
user-invokable: true
license: MIT
metadata:
author: lgzarturo
category: mobile
compatibility:
tools: [claude, codex, gemini, agy, opencode]
stacks:
languages: [kotlin, java]
frameworks: [android]
risk:
level: medium
can_execute_shell: true
can_modify_files: true
requires_network: false
inputs: []
outputs: []
quality:
reviewed_by: codeconductor-core
version: 0.1.0
---
# Android + Kotlin (Jetpack Compose & Media3)
This playbook defines the architecture, UI design system, performance guidelines, and testing strategy for native Android applications.
---
## 1. Lead Architect & Modularization
### Architecture (MVI / MVVM with Clean Architecture)
- Prefer **MVI (Model-View-Intent)** for state management. Ensure a strict unidirectional data flow:
- **UiState**: Single source of truth for the UI state.
- **UiIntent**: Actions initiated by the user or system.
- **UiEffect**: One-time events (navigation, toasts, snackbars).
- Separate the project into clean logical modules:
- `app`: Application entry point, launcher activity, global dependency injection configuration.
- `core`: Shared resources, network layer, media playback logic (ExoPlayer/Media3), storage.
- `feature`: Individual business features (isolated and independent of other features).
### Dependency Injection (Hilt)
- Annotate the Application class with `@HiltAndroidApp`.
- Annotate Activities and Fragments with `@AndroidEntryPoint`.
- Use constructor injection for ViewModels with `@HiltViewModel`.
---
## 2. Jetpack Compose UI/UX
### Recomposition & State Management
- Consume `StateFlow` from ViewModels safely using `collectAsStateWithLifecycle()` to respect the activity lifecycle.
- Keep data models immutable. Mark configuration classes with `@Immutable` or `@Stable` to prevent redundant recompositions.
- Use `contentType` and `key` in `LazyColumn` and `LazyRow` to optimize item recycling and rendering performance.
- Avoid fixed hardcoded margins. Apply `WindowInsets` to support modern edge-to-edge screens.
---
## 3. Performance & Multimedia (Media3 / ExoPlayer)
### Playback & Services
- Run `ExoPlayer` / `Media3` inside a robust `Foreground Service` (using `MediaSessionService`) to ensure playback continues when the app is in the background.
- Clean up resources: release player instances in `onDestroy` or when the service is stopped.
- Control wake locks strictly to avoid draining the user's battery when playback is paused.
### Kotlin Coroutines & Threading
- Always inject `CoroutineDispatcher` instances instead of hardcoding them:
- `Dispatchers.IO`: Database access, network operations, file reading.
- `Dispatchers.Default`: CPU-intensive operations (parsing JSON, filtering large lists).
- `Dispatchers.Main`: UI modifications and light interactions.
### R8 & Resource Optimization
- Enable shrinking and obfuscation in production builds:
- `isMinifyEnabled = true`
- `isShrinkResources = true`
- Convert all static images to the modern `WebP` format to minimize the application binary size.
---
## 4. Quality Assurance & Automated Testing
### Unit Testing
- Use JUnit 5 and MockK for mocking dependencies.
- Test ViewModels and Reducers by asserting StateFlow updates. Use `Turbine` to easily test Kotlin Flows.
- Mock all multimedia/ExoPlayer components to avoid loading actual audio/video resources in unit tests.
### UI Testing
- Create UI tests using Compose test rules: `createComposeRule()` or `createAndroidComposeRule<MainActivity>()`.
- Assert correct rendering, visibility states, and user interactions without executing real network calls.
---
## 5. Key Gradle & ADB Commands
Use these commands for building, checking code style, running tests, and profiling performance:
| Command | Action |
| ------- | ------ |
| `./gradlew init` | Initialize a new Gradle project structure. |
| `./gradlew app:dependencies` | List all dependencies of the application module. |
| `./gradlew assembleDebug` | Validate compilation and generate debug APK. |
| `./gradlew ktlintCheck` | Run KtLint check to enforce Kotlin style guidelines. |
| `./gradlew lintDebug` | Run Android Lint tool to inspect code quality and UI issues. |
| `./gradlew testDebugUnitTest` | Run unit tests for debug build variant. |
| `./gradlew connectedAndroidTest` | Run instrumented UI tests on connected emulator or device. |
| `./gradlew assembleRelease` | Generate release build variant with R8 optimizations. |
| `adb shell dumpsys batterystats` | Profile system battery consumption metrics. |
| `adb shell am dumpheap <package> heap.hprof` | Dump application heap memory file for profiling. |
# Eloquent ORM
## Model Patterns
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'title',
'slug',
'content',
'published_at',
'user_id',
];
protected $casts = [
'published_at' => 'datetime',
'metadata' => 'array',
'is_featured' => 'boolean',
];
// Accessor using new Attribute syntax (Laravel 9+)
protected function title(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
);
}
// Mutator for computed property
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => str($this->content)->limit(100),
);
}
}
```
## Relationships
```php
// One-to-Many
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();
}
public function oldestPost(): HasOne
{
return $this->hasOne(Post::class)->oldestOfMany();
}
}
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
// Inverse relationship
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
// Many-to-Many with Pivot
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withPivot('expires_at', 'assigned_by')
->withTimestamps()
->using(RoleUser::class); // Custom pivot model
}
}
// Has Many Through
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
// Polymorphic Relations
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Post extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// Many-to-Many Polymorphic
class Tag extends Model
{
public function posts(): MorphToMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function videos(): MorphToMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
}
```
## Query Scopes
```php
class Post extends Model
{
// Local scope
public function scopePublished($query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopePopular($query, int $threshold = 100): void
{
$query->where('views', '>=', $threshold);
}
// Global scope
protected static function booted(): void
{
static::addGlobalScope('active', function ($query) {
$query->where('status', 'active');
});
}
}
// Usage
$posts = Post::published()->popular(500)->get();
// Custom Scope Class
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class AncientScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('created_at', '<', now()->subYears(10));
}
}
// Apply in model
protected static function booted(): void
{
static::addGlobalScope(new AncientScope);
}
```
## Custom Casts
```php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Money implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): float
{
return $value / 100; // Store cents, return dollars
}
public function set($model, string $key, $value, array $attributes): int
{
return (int) ($value * 100);
}
}
// In model
protected $casts = [
'price' => Money::class,
];
```
## Query Optimization
```php
// Eager Loading (prevent N+1)
$posts = Post::with(['user', 'comments.user'])->get();
// Lazy Eager Loading
$posts = Post::all();
$posts->load('user');
// Eager Load with Constraints
$users = User::with(['posts' => function ($query) {
$query->where('published', true)->orderBy('created_at', 'desc');
}])->get();
// Count relationships efficiently
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
// Exists checks
$users = User::withExists('posts')->get();
// Chunk for large datasets
Post::chunk(100, function ($posts) {
foreach ($posts as $post) {
// Process post
}
});
// Lazy collection for memory efficiency
Post::lazy()->each(function ($post) {
// Process one at a time
});
```
## Model Events
```php
class Post extends Model
{
protected static function booted(): void
{
static::creating(function ($post) {
$post->slug = str($post->title)->slug();
});
static::updating(function ($post) {
if ($post->isDirty('title')) {
$post->slug = str($post->title)->slug();
}
});
static::deleted(function ($post) {
$post->images()->delete();
});
}
}
// Using Observers
namespace App\Observers;
class PostObserver
{
public function creating(Post $post): void
{
$post->user_id = auth()->id();
}
public function updated(Post $post): void
{
cache()->forget("post.{$post->id}");
}
}
// Register in AppServiceProvider
use App\Models\Post;
use App\Observers\PostObserver;
public function boot(): void
{
Post::observe(PostObserver::class);
}
```
## Advanced Queries
```php
// Subqueries
$users = User::select(['id', 'name'])
->addSelect(['latest_post_title' => Post::select('title')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1)
])->get();
// When conditional queries
$posts = Post::query()
->when($search, fn ($query) => $query->where('title', 'like', "%{$search}%"))
->when($category, fn ($query) => $query->where('category_id', $category))
->get();
// Database transactions
DB::transaction(function () {
$user = User::create([...]);
$user->profile()->create([...]);
$user->assignRole('member');
});
// Pessimistic locking
$user = User::where('id', 1)->lockForUpdate()->first();
// Upserts
User::upsert(
[
['email' => 'john@example.com', 'name' => 'John'],
['email' => 'jane@example.com', 'name' => 'Jane'],
],
['email'], // Unique columns
['name'] // Columns to update
);
```
## Performance Tips
1. **Always eager load relationships** - Avoid N+1 queries
2. **Use chunking for large datasets** - Prevent memory exhaustion
3. **Index foreign keys** - Speed up joins
4. **Use select() to limit columns** - Reduce data transfer
5. **Cache expensive queries** - Use Redis/Memcached
6. **Use database indexing** - Add indexes in migrations
7. **Avoid using model events for heavy operations** - Use queues instead
8. **Use lazy collections** - For processing large datasets
# Livewire Components
## Component Patterns
```php
namespace App\Http\Livewire;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\WithFileUploads;
use App\Models\Post;
class PostList extends Component
{
use WithPagination, WithFileUploads;
public string $search = '';
public string $sortBy = 'created_at';
public string $sortDirection = 'desc';
public ?int $categoryId = null;
protected $queryString = [
'search' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'categoryId' => ['except' => null],
];
public function updatingSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
return view('livewire.post-list', [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->categoryId, fn($q) => $q->where('category_id', $this->categoryId))
->orderBy($this->sortBy, $this->sortDirection)
->paginate(10),
]);
}
}
```
## Blade Template
```blade
<div>
{{-- Search --}}
<input
type="text"
wire:model.debounce.300ms="search"
placeholder="Search posts..."
class="form-input"
>
{{-- Filter by category --}}
<select wire:model="categoryId">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
{{-- Sortable table --}}
<table>
<thead>
<tr>
<th wire:click="sortBy('title')" style="cursor: pointer">
Title
@if($sortBy === 'title')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
<th wire:click="sortBy('created_at')" style="cursor: pointer">
Date
@if($sortBy === 'created_at')
<span>{{ $sortDirection === 'asc' ? '↑' : '↓' }}</span>
@endif
</th>
</tr>
</thead>
<tbody>
@foreach($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>{{ $post->created_at->diffForHumans() }}</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Pagination --}}
{{ $posts->links() }}
{{-- Loading states --}}
<div wire:loading wire:target="search">
Searching...
</div>
</div>
```
## Form Component
```php
namespace App\Http\Livewire;
use Livewire\Component;
use App\Models\Post;
class PostForm extends Component
{
public ?Post $post = null;
public string $title = '';
public string $content = '';
public array $tags = [];
public $image;
protected function rules(): array
{
return [
'title' => 'required|min:3|max:255',
'content' => 'required|min:10',
'tags' => 'array|max:5',
'tags.*' => 'exists:tags,id',
'image' => 'nullable|image|max:2048',
];
}
public function mount(?Post $post = null): void
{
if ($post) {
$this->post = $post;
$this->title = $post->title;
$this->content = $post->content;
$this->tags = $post->tags->pluck('id')->toArray();
}
}
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
public function save(): void
{
$validated = $this->validate();
if ($this->post) {
$this->post->update($validated);
$message = 'Post updated successfully!';
} else {
$this->post = Post::create($validated);
$message = 'Post created successfully!';
}
if ($this->image) {
$this->post->update([
'image_path' => $this->image->store('posts', 'public'),
]);
}
$this->post->tags()->sync($this->tags);
session()->flash('message', $message);
$this->redirect(route('posts.show', $this->post));
}
public function render()
{
return view('livewire.post-form');
}
}
```
## Form Template
```blade
<form wire:submit.prevent="save">
{{-- Title --}}
<div>
<label for="title">Title</label>
<input
type="text"
wire:model.defer="title"
id="title"
class="@error('title') border-red-500 @enderror"
>
@error('title')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Content --}}
<div>
<label for="content">Content</label>
<textarea
wire:model.defer="content"
id="content"
class="@error('content') border-red-500 @enderror"
></textarea>
@error('content')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- Tags --}}
<div>
<label>Tags</label>
@foreach($availableTags as $tag)
<label>
<input
type="checkbox"
wire:model="tags"
value="{{ $tag->id }}"
>
{{ $tag->name }}
</label>
@endforeach
@error('tags')
<span class="text-red-500">{{ $message }}</span>
@enderror
</div>
{{-- File Upload --}}
<div>
<label>Image</label>
<input type="file" wire:model="image">
@error('image')
<span class="text-red-500">{{ $message }}</span>
@enderror
{{-- Upload progress --}}
<div wire:loading wire:target="image">
Uploading...
</div>
{{-- Preview --}}
@if ($image)
<img src="{{ $image->temporaryUrl() }}" alt="Preview">
@endif
</div>
{{-- Submit --}}
<button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</button>
</form>
@if (session()->has('message'))
<div class="alert alert-success">
{{ session('message') }}
</div>
@endif
```
## Real-time Validation
```php
class PostForm extends Component
{
public string $title = '';
protected $rules = [
'title' => 'required|min:3|unique:posts,title',
];
// Real-time validation
public function updated($propertyName): void
{
$this->validateOnly($propertyName);
}
// Custom validation messages
protected $messages = [
'title.required' => 'The post title is required.',
'title.min' => 'The title must be at least 3 characters.',
'title.unique' => 'This title is already taken.',
];
// Custom attribute names
protected $validationAttributes = [
'title' => 'post title',
];
}
```
## Events
```php
// Emit event
class PostList extends Component
{
public function deletePost($postId): void
{
Post::find($postId)->delete();
$this->emit('postDeleted', $postId);
}
}
// Listen to event
class PostStats extends Component
{
protected $listeners = ['postDeleted' => 'updateStats'];
public function updateStats($postId): void
{
// Update statistics
}
}
// Emit to specific component
$this->emitTo('post-stats', 'refresh');
// Emit to parent/children
$this->emitUp('saved');
$this->emitSelf('refresh');
// Browser events
$this->dispatchBrowserEvent('post-saved', ['id' => $post->id]);
```
## Listen to Browser Events
```blade
<div
x-data
@post-saved.window="alert('Post saved!')"
>
<!-- content -->
</div>
<script>
window.addEventListener('post-saved', event => {
console.log('Post ID:', event.detail.id);
});
</script>
```
## Polling
```blade
{{-- Poll every 2 seconds --}}
<div wire:poll.2s>
Current time: {{ now() }}
</div>
{{-- Poll specific action --}}
<div wire:poll.5s="checkStatus">
Status: {{ $status }}
</div>
{{-- Keep polling until condition --}}
<div wire:poll.keep-alive.2s>
<!-- content -->
</div>
```
## Loading States
```blade
{{-- Basic loading state --}}
<div wire:loading>
Loading...
</div>
{{-- Target specific action --}}
<div wire:loading wire:target="save">
Saving...
</div>
{{-- Hide element while loading --}}
<div wire:loading.remove>
Content (hidden during load)
</div>
{{-- Delay loading indicator --}}
<div wire:loading.delay>
This appears after 200ms
</div>
{{-- Custom delay --}}
<div wire:loading.delay.longest>
This appears after 1s
</div>
{{-- Loading classes --}}
<button
wire:click="save"
wire:loading.class="opacity-50"
wire:loading.class.remove="bg-blue-500"
>
Save
</button>
{{-- Loading attributes --}}
<button
wire:click="save"
wire:loading.attr="disabled"
>
Save
</button>
```
## Traits
```php
// Pagination
use Livewire\WithPagination;
class PostList extends Component
{
use WithPagination;
public function render()
{
return view('livewire.post-list', [
'posts' => Post::paginate(10),
]);
}
}
// File uploads
use Livewire\WithFileUploads;
class UploadPhoto extends Component
{
use WithFileUploads;
public $photo;
public function save(): void
{
$this->validate([
'photo' => 'image|max:1024',
]);
$this->photo->store('photos');
}
}
```
## Authorization
```php
class PostForm extends Component
{
public Post $post;
public function mount(Post $post): void
{
$this->authorize('update', $post);
$this->post = $post;
}
public function save(): void
{
$this->authorize('update', $this->post);
// Save logic
}
}
```
## Performance Tips
1. **Use wire:model.defer** - Batch updates on form submit
2. **Lazy load components** - Use wire:init for heavy operations
3. **Cache computed properties** - Use #[Computed] attribute
4. **Disable polling when hidden** - Use wire:poll.visible
5. **Optimize queries** - Eager load relationships
6. **Use wire:key** - Prevent re-rendering entire lists
7. **Debounce input** - Use wire:model.debounce
8. **Use pagination** - Don't load all records at once
```php
use Livewire\Attributes\Computed;
class PostList extends Component
{
#[Computed]
public function posts()
{
return Post::with('user')->paginate(10);
}
public function render()
{
return view('livewire.post-list');
}
}
```
```blade
{{-- Access computed property --}}
@foreach($this->posts as $post)
<!-- content -->
@endforeach
```
# Queue System
## Job Patterns
```php
namespace App\Jobs;
use App\Models\Post;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $timeout = 120;
public $maxExceptions = 3;
public $backoff = [60, 120, 300]; // Exponential backoff
public function __construct(
public Post $post,
public ?User $user = null,
) {}
public function handle(): void
{
// Process the post
$this->post->update(['processed' => true]);
// Can access injected dependencies
$analytics = app(AnalyticsService::class);
$analytics->trackPostProcessed($this->post);
}
public function failed(\Throwable $exception): void
{
// Handle job failure
\Log::error('Post processing failed', [
'post_id' => $this->post->id,
'error' => $exception->getMessage(),
]);
}
}
```
## Dispatching Jobs
```php
use App\Jobs\ProcessPost;
// Dispatch immediately
ProcessPost::dispatch($post);
// Dispatch to specific queue
ProcessPost::dispatch($post)->onQueue('processing');
// Delayed dispatch
ProcessPost::dispatch($post)->delay(now()->addMinutes(10));
// Dispatch after database commit
ProcessPost::dispatch($post)->afterCommit();
// Dispatch conditionally
ProcessPost::dispatchIf($condition, $post);
ProcessPost::dispatchUnless($condition, $post);
// Synchronous dispatch (no queue)
ProcessPost::dispatchSync($post);
// Dispatch after response
ProcessPost::dispatchAfterResponse($post);
```
## Job Chaining
```php
use App\Jobs\{OptimizeImage, GenerateThumbnail, PublishPost};
// Chain jobs
OptimizeImage::withChain([
new GenerateThumbnail($post),
new PublishPost($post),
])->dispatch($post);
// Catch failures in chain
Bus::chain([
new ProcessPost($post),
new NotifyUser($user),
])->catch(function (\Throwable $e) {
// Handle failure
})->dispatch();
```
## Job Batching
```php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new ProcessPost($post1),
new ProcessPost($post2),
new ProcessPost($post3),
])->then(function (Batch $batch) {
// All jobs completed successfully
})->catch(function (Batch $batch, \Throwable $e) {
// First batch job failure detected
})->finally(function (Batch $batch) {
// The batch has finished executing
})->name('Process Posts')
->allowFailures()
->dispatch();
// Check batch status
$batch = Bus::findBatch($batchId);
if ($batch->finished()) {
// Batch is complete
}
if ($batch->cancelled()) {
// Batch was cancelled
}
// Add jobs to existing batch
$batch->add([
new ProcessPost($post4),
]);
```
## Rate Limiting
```php
use Illuminate\Support\Facades\Redis;
class ProcessPost implements ShouldQueue
{
public function handle(): void
{
Redis::throttle('process-posts')
->block(0)
->allow(10)
->every(60)
->then(function () {
// Lock acquired, process job
}, function () {
// Could not acquire lock, release job back
$this->release(10);
});
}
}
// Or using middleware
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [new RateLimited('process-posts')];
}
```
## Job Middleware
```php
namespace App\Jobs\Middleware;
class RateLimitedByUser
{
public function handle($job, $next): void
{
Redis::throttle("user:{$job->user->id}")
->allow(10)
->every(60)
->then(function () use ($job, $next) {
$next($job);
}, function () use ($job) {
$job->release(10);
});
}
}
// Use in job
use App\Jobs\Middleware\RateLimitedByUser;
public function middleware(): array
{
return [new RateLimitedByUser];
}
// Skip middleware
use Illuminate\Queue\Middleware\WithoutOverlapping;
public function middleware(): array
{
return [
(new WithoutOverlapping($this->user->id))->expireAfter(180),
];
}
```
## Unique Jobs
```php
use Illuminate\Contracts\Queue\ShouldBeUnique;
class ProcessPost implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 3600;
public function __construct(
public Post $post,
) {}
public function uniqueId(): string
{
return $this->post->id;
}
}
// Or use unique until processing
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class ProcessPost implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
// ...
}
```
## Failed Jobs
```php
// Retry failed job
php artisan queue:retry <job-id>
// Retry all failed jobs
php artisan queue:retry all
// Flush failed jobs
php artisan queue:flush
// Prune failed jobs
php artisan queue:prune-failed --hours=48
// Handle in code
use Illuminate\Support\Facades\Queue;
Queue::failing(function (JobFailed $event) {
\Log::error('Job failed', [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
```
## Queue Workers
```bash
# Start worker
php artisan queue:work
# Process specific queue
php artisan queue:work --queue=high,default
# Process one job
php artisan queue:work --once
# Stop worker gracefully
php artisan queue:restart
# Timeout settings
php artisan queue:work --timeout=60
# Memory limit
php artisan queue:work --memory=512
# Max jobs before restart
php artisan queue:work --max-jobs=1000
# Max time before restart
php artisan queue:work --max-time=3600
```
## Horizon Setup
```php
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'maxProcesses' => 10,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 512,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
'supervisor-2' => [
'connection' => 'redis',
'queue' => ['high', 'default'],
'balance' => 'auto',
'maxProcesses' => 5,
'tries' => 3,
],
],
],
];
// Start Horizon
php artisan horizon
// Terminate Horizon
php artisan horizon:terminate
// Pause workers
php artisan horizon:pause
// Continue workers
php artisan horizon:continue
// Check status
php artisan horizon:status
```
## Monitoring
```php
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
// In AppServiceProvider
public function boot(): void
{
Queue::before(function (JobProcessing $event) {
// Called before job is processed
});
Queue::after(function (JobProcessed $event) {
// Called after job is processed
\Log::info('Job processed', [
'job' => $event->job->resolveName(),
'time' => $event->job->processingTime(),
]);
});
Queue::failing(function (JobFailed $event) {
// Called when job fails
\Log::error('Job failed', [
'job' => $event->job->resolveName(),
'exception' => $event->exception,
]);
});
}
```
## Queue Configuration
```php
// config/queue.php
return [
'default' => env('QUEUE_CONNECTION', 'sync'),
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX'),
'queue' => env('SQS_QUEUE'),
'region' => env('AWS_DEFAULT_REGION'),
],
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
```
## Best Practices
1. **Keep jobs small and focused** - Single responsibility
2. **Make jobs idempotent** - Safe to run multiple times
3. **Use type hints** - Better error detection
4. **Set reasonable timeouts** - Prevent hanging jobs
5. **Monitor failed jobs** - Set up alerts
6. **Use batching for bulk operations** - Better performance
7. **Implement proper error handling** - Use failed() method
8. **Use unique jobs** - Prevent duplicate processing
9. **Queue long-running tasks** - Don't block requests
10. **Use Horizon for Redis queues** - Better monitoring
# Routing & API Resources
## Route Patterns
```php
// routes/web.php
use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;
// Resource routes
Route::resource('posts', PostController::class);
// API resource (excludes create/edit)
Route::apiResource('posts', PostController::class);
// Partial resource
Route::resource('posts', PostController::class)->only(['index', 'show']);
Route::resource('posts', PostController::class)->except(['destroy']);
// Nested resources
Route::resource('posts.comments', CommentController::class);
// Route groups
Route::prefix('admin')->middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::resource('users', UserController::class);
});
// Named routes
Route::get('/posts/{post}', [PostController::class, 'show'])->name('posts.show');
// Route model binding
Route::get('/posts/{post:slug}', [PostController::class, 'show']);
// Multiple bindings
Route::get('/users/{user}/posts/{post:slug}', function (User $user, Post $post) {
return view('posts.show', compact('user', 'post'));
});
```
## API Routes
```php
// routes/api.php
use App\Http\Controllers\Api\V1\PostController;
Route::prefix('v1')->group(function () {
// Public routes
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});
});
// Rate limiting
Route::middleware('throttle:60,1')->group(function () {
Route::apiResource('posts', PostController::class);
});
```
## Controllers
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Models\Post;
use Illuminate\Http\Response;
class PostController extends Controller
{
public function index()
{
$posts = Post::with('user')
->published()
->paginate(15);
return new PostCollection($posts);
}
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return new PostResource($post);
}
public function show(Post $post)
{
$post->load(['user', 'comments.user']);
return new PostResource($post);
}
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
return new PostResource($post);
}
public function destroy(Post $post)
{
$post->delete();
return response()->noContent();
}
}
```
## Form Requests
```php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or check user permissions
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'unique:posts,slug'],
'content' => ['required', 'string'],
'category_id' => ['required', 'exists:categories,id'],
'tags' => ['array'],
'tags.*' => ['exists:tags,id'],
'published_at' => ['nullable', 'date', 'after:now'],
];
}
public function messages(): array
{
return [
'title.required' => 'Please provide a post title',
'slug.unique' => 'This slug is already taken',
];
}
// Prepare data before validation
protected function prepareForValidation(): void
{
$this->merge([
'slug' => str($this->title)->slug(),
]);
}
}
class UpdatePostRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'slug' => [
'sometimes',
'string',
Rule::unique('posts', 'slug')->ignore($this->post)
],
'content' => ['sometimes', 'string'],
];
}
}
```
## API Resources
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->when($request->route()->named('posts.show'), $this->content),
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
// Relationships
'author' => new UserResource($this->whenLoaded('user')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'comments_count' => $this->when($this->comments_count !== null, $this->comments_count),
// Conditional fields
'is_published' => $this->when($request->user()?->isAdmin(), $this->isPublished()),
// Pivot data
'role' => $this->whenPivotLoaded('role_user', function () {
return $this->pivot->role_name;
}),
// Links
'links' => [
'self' => route('api.posts.show', $this->id),
],
];
}
public function with(Request $request): array
{
return [
'meta' => [
'version' => '1.0.0',
],
];
}
}
```
## Resource Collections
```php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PostCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
],
'links' => [
'self' => $request->url(),
],
];
}
}
// Or use anonymous collection
return PostResource::collection($posts);
```
## Middleware
```php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next)
{
if (!$request->user()?->isAdmin()) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
// Register in app/Http/Kernel.php
protected $middlewareAliases = [
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
];
// Use in routes
Route::middleware('admin')->group(function () {
Route::resource('users', UserController::class);
});
```
## Response Helpers
```php
// JSON responses
return response()->json(['data' => $posts], 200);
// Created response
return response()->json($post, 201);
// No content
return response()->noContent();
// Custom headers
return response()->json($data)->header('X-Custom-Header', 'Value');
// Download
return response()->download($pathToFile);
// Stream
return response()->streamDownload(function () {
echo 'CSV content...';
}, 'export.csv');
```
## Route Caching
```bash
# Generate route cache
php artisan route:cache
# Clear route cache
php artisan route:clear
# List all routes
php artisan route:list
# Filter routes
php artisan route:list --name=api
php artisan route:list --path=posts
```
## API Versioning
```php
// routes/api.php
Route::prefix('v1')->name('v1.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});
Route::prefix('v2')->name('v2.')->group(function () {
Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});
```
## CORS Configuration
```php
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['http://localhost:3000'],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
```
# Testing
## Feature Tests
```php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\{User, Post};
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'Test Post',
'content' => 'This is a test post content.',
]);
$response->assertStatus(201)
->assertJson([
'data' => [
'title' => 'Test Post',
],
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
}
public function test_guest_cannot_create_post(): void
{
$response = $this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
]);
$response->assertStatus(401);
}
public function test_post_requires_valid_data(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => 'AB', // Too short
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'content']);
}
public function test_user_can_view_their_posts(): void
{
$user = User::factory()->create();
$posts = Post::factory()->count(3)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/api/posts');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content', 'created_at'],
],
]);
}
public function test_user_can_update_own_post(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
'content' => $post->content,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('posts', [
'id' => $post->id,
'title' => 'Updated Title',
]);
}
public function test_user_cannot_update_others_post(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->put("/api/posts/{$post->id}", [
'title' => 'Updated Title',
]);
$response->assertStatus(403);
}
}
```
## Unit Tests
```php
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostServiceTest extends TestCase
{
use RefreshDatabase;
public function test_generates_unique_slug(): void
{
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post', $slug);
}
public function test_increments_slug_on_duplicate(): void
{
Post::factory()->create(['slug' => 'test-post']);
$service = new PostService();
$slug = $service->generateSlug('Test Post');
$this->assertEquals('test-post-1', $slug);
}
public function test_post_excerpt_returns_limited_content(): void
{
$post = new Post(['content' => str_repeat('a', 200)]);
$excerpt = $post->excerpt;
$this->assertLessThanOrEqual(100, strlen($excerpt));
}
}
```
## Pest PHP
```php
<?php
use App\Models\{User, Post};
it('allows authenticated users to create posts', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])
->assertStatus(201);
expect(Post::count())->toBe(1);
});
it('prevents guests from creating posts', function () {
$this->post('/api/posts', [
'title' => 'Test Post',
'content' => 'Content',
])->assertStatus(401);
});
test('post requires title and content', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/api/posts', [])
->assertJsonValidationErrors(['title', 'content']);
});
// Datasets
it('validates title length', function (string $title, bool $shouldPass) {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/api/posts', [
'title' => $title,
'content' => 'Content',
]);
if ($shouldPass) {
$response->assertStatus(201);
} else {
$response->assertJsonValidationErrors(['title']);
}
})->with([
['AB', false], // Too short
['ABC', true], // Minimum valid
[str_repeat('A', 255), true], // Maximum valid
[str_repeat('A', 256), false], // Too long
]);
// Hooks
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
// Cleanup
});
```
## Factories
```php
namespace Database\Factories;
use App\Models\{User, Category};
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'slug' => fake()->slug(),
'content' => fake()->paragraphs(3, true),
'excerpt' => fake()->text(100),
'published_at' => fake()->dateTimeBetween('-1 year', 'now'),
'user_id' => User::factory(),
'category_id' => Category::factory(),
];
}
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(),
]);
}
public function forUser(User $user): static
{
return $this->state(fn (array $attributes) => [
'user_id' => $user->id,
]);
}
public function configure(): static
{
return $this->afterCreating(function (Post $post) {
$post->tags()->attach(
Tag::factory()->count(3)->create()
);
});
}
}
// Usage
$post = Post::factory()->create();
$unpublished = Post::factory()->unpublished()->create();
$posts = Post::factory()->count(10)->create();
$userPosts = Post::factory()->forUser($user)->count(5)->create();
// With relationships
$post = Post::factory()
->has(Comment::factory()->count(3))
->create();
// For relationship
$posts = Post::factory()
->count(3)
->for($user)
->create();
```
## Mocking
```php
use App\Services\ExternalApiService;
use Illuminate\Support\Facades\Http;
public function test_fetches_data_from_external_api(): void
{
Http::fake([
'api.example.com/*' => Http::response([
'data' => ['id' => 1, 'name' => 'Test'],
], 200),
]);
$service = new ExternalApiService();
$result = $service->fetchData();
$this->assertEquals('Test', $result['name']);
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/data' &&
$request->hasHeader('Authorization');
});
}
// Mock events
use Illuminate\Support\Facades\Event;
Event::fake([PostCreated::class]);
// Test code that dispatches events
Event::assertDispatched(PostCreated::class, function ($event) {
return $event->post->id === 1;
});
// Mock queues
use Illuminate\Support\Facades\Queue;
Queue::fake();
// Test code that dispatches jobs
Queue::assertPushed(ProcessPost::class);
Queue::assertPushed(ProcessPost::class, 2);
Queue::assertPushed(ProcessPost::class, function ($job) {
return $job->post->id === 1;
});
// Mock notifications
use Illuminate\Support\Facades\Notification;
Notification::fake();
// Test code that sends notifications
Notification::assertSentTo($user, PostPublished::class);
// Mock storage
use Illuminate\Support\Facades\Storage;
Storage::fake('public');
// Test file upload
Storage::disk('public')->assertExists('file.jpg');
Storage::disk('public')->assertMissing('missing.jpg');
```
## Database Testing
```php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class PostTest extends TestCase
{
use RefreshDatabase; // Migrate database before each test
// Or use transactions
use DatabaseTransactions; // Rollback after each test
public function test_database_assertions(): void
{
$post = Post::factory()->create([
'title' => 'Test Post',
]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
]);
$post->delete();
$this->assertDatabaseMissing('posts', [
'id' => $post->id,
]);
$this->assertSoftDeleted('posts', [
'id' => $post->id,
]);
}
public function test_model_exists(): void
{
$post = Post::factory()->create();
$this->assertModelExists($post);
$post->delete();
$this->assertModelMissing($post);
}
}
```
## API Testing
```php
public function test_api_returns_paginated_posts(): void
{
Post::factory()->count(30)->create();
$response = $this->get('/api/posts');
$response->assertStatus(200)
->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'content'],
],
'meta' => ['total', 'current_page', 'last_page'],
'links' => ['first', 'last', 'prev', 'next'],
])
->assertJsonCount(15, 'data'); // Default per page
}
public function test_api_filters_posts_by_category(): void
{
$category = Category::factory()->create();
Post::factory()->count(5)->create(['category_id' => $category->id]);
Post::factory()->count(5)->create();
$response = $this->get("/api/posts?category={$category->id}");
$response->assertJsonCount(5, 'data')
->assertJson([
'data' => [
['category_id' => $category->id],
],
]);
}
```
## Authentication Testing
```php
use Laravel\Sanctum\Sanctum;
public function test_authenticated_user_can_access_endpoint(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['*']);
$response = $this->get('/api/user');
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function test_user_with_wrong_ability_cannot_access(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['view-posts']);
$response = $this->post('/api/posts', [
'title' => 'Test',
'content' => 'Content',
]);
$response->assertStatus(403);
}
```
## Running Tests
```bash
# Run all tests
php artisan test
# Run specific test
php artisan test --filter=test_user_can_create_post
# Run test file
php artisan test tests/Feature/PostTest.php
# Parallel testing
php artisan test --parallel
# With coverage
php artisan test --coverage
# Coverage minimum
php artisan test --coverage --min=80
# Stop on failure
php artisan test --stop-on-failure
# Pest specific
./vendor/bin/pest
./vendor/bin/pest --filter=PostTest
./vendor/bin/pest --coverage
```
## Best Practices
1. **Use RefreshDatabase** - Clean database for each test
2. **Use factories** - Don't manually create test data
3. **Test one thing** - Each test should verify one behavior
4. **Use descriptive names** - test_user_can_create_post
5. **AAA pattern** - Arrange, Act, Assert
6. **Mock external services** - Don't make real API calls
7. **Fake queues and events** - Test async code synchronously
8. **Test edge cases** - Invalid data, permissions, etc.
9. **Achieve >85% coverage** - Test critical paths
10. **Run tests in CI/CD** - Automate test execution
---
name: laravel-specialist
description: Build and configure Laravel 10+ applications, including creating Eloquent models and relationships, implementing Sanctum authentication, configuring Horizon queues, designing RESTful APIs with API resources, and building reactive interfaces with Livewire. Use when creating Laravel models, setting up queue workers, implementing Sanctum auth flows, building Livewire components, optimising Eloquent queries, or writing Pest/PHPUnit tests for Laravel features.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: backend
triggers: Laravel, Eloquent, PHP framework, Laravel API, Artisan, Blade templates, Laravel queues, Livewire, Laravel testing, Sanctum, Horizon
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, test-master, devops-engineer, security-reviewer
---
# Laravel Specialist
Senior Laravel specialist with deep expertise in Laravel 10+, Eloquent ORM, and modern PHP 8.2+ development.
## Core Workflow
1. **Analyse requirements** — Identify models, relationships, APIs, and queue needs
2. **Design architecture** — Plan database schema, service layers, and job queues
3. **Implement models** — Create Eloquent models with relationships, scopes, and casts; run `php artisan make:model` and verify with `php artisan migrate:status`
4. **Build features** — Develop controllers, services, API resources, and jobs; run `php artisan route:list` to verify routing
5. **Test thoroughly** — Write feature and unit tests; run `php artisan test` before considering any step complete (target >85% coverage)
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Eloquent ORM | `references/eloquent.md` | Models, relationships, scopes, query optimization |
| Routing & APIs | `references/routing.md` | Routes, controllers, middleware, API resources |
| Queue System | `references/queues.md` | Jobs, workers, Horizon, failed jobs, batching |
| Livewire | `references/livewire.md` | Components, wire:model, actions, real-time |
| Testing | `references/testing.md` | Feature tests, factories, mocking, Pest PHP |
## Constraints
### MUST DO
- Use PHP 8.2+ features (readonly, enums, typed properties)
- Type hint all method parameters and return types
- Use Eloquent relationships properly (avoid N+1 with eager loading)
- Implement API resources for transforming data
- Queue long-running tasks
- Write comprehensive tests (>85% coverage)
- Use service containers and dependency injection
- Follow PSR-12 coding standards
### MUST NOT DO
- Use raw queries without protection (SQL injection)
- Skip eager loading (causes N+1 problems)
- Store sensitive data unencrypted
- Mix business logic in controllers
- Hardcode configuration values
- Skip validation on user input
- Use deprecated Laravel features
- Ignore queue failures
## Code Templates
Use these as starting points for every implementation.
### Eloquent Model
```php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
final class Post extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['title', 'body', 'status', 'user_id'];
protected $casts = [
'status' => PostStatus::class, // backed enum
'published_at' => 'immutable_datetime',
];
// Relationships — always eager-load via ::with() at call site
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
// Local scope
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PostStatus::Published);
}
}
```
### Migration
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->softDeletes();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
```
### API Resource
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
'status' => $this->status->value,
'published_at' => $this->published_at?->toIso8601String(),
'author' => new UserResource($this->whenLoaded('author')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
];
}
}
```
### Queued Job
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class PublishPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
private readonly Post $post,
) {}
public function handle(): void
{
$this->post->update([
'status' => PostStatus::Published,
'published_at' => now(),
]);
}
public function failed(\Throwable $e): void
{
// Log or notify — never silently swallow failures
logger()->error('PublishPost failed', ['post' => $this->post->id, 'error' => $e->getMessage()]);
}
}
```
### Feature Test (Pest)
```php
<?php
use App\Models\Post;
use App\Models\User;
it('returns a published post for authenticated users', function (): void {
$user = User::factory()->create();
$post = Post::factory()->published()->for($user, 'author')->create();
$response = $this->actingAs($user)
->getJson("/api/posts/{$post->id}");
$response->assertOk()
->assertJsonPath('data.status', 'published')
->assertJsonPath('data.author.id', $user->id);
});
it('queues a publish job when a draft is submitted', function (): void {
Queue::fake();
$user = User::factory()->create();
$post = Post::factory()->draft()->for($user, 'author')->create();
$this->actingAs($user)
->postJson("/api/posts/{$post->id}/publish")
->assertAccepted();
Queue::assertPushed(PublishPost::class, fn ($job) => $job->post->is($post));
});
```
## Validation Checkpoints
Run these at each workflow stage to confirm correctness before proceeding:
| Stage | Command | Expected Result |
|-------|---------|-----------------|
| After migration | `php artisan migrate:status` | All migrations show `Ran` |
| After routing | `php artisan route:list --path=api` | New routes appear with correct verbs |
| After job dispatch | `php artisan queue:work --once` | Job processes without exception |
| After implementation | `php artisan test --coverage` | >85% coverage, 0 failures |
| Before PR | `./vendor/bin/pint --test` | PSR-12 linting passes |
## Knowledge Reference
Laravel 10+, Eloquent ORM, PHP 8.2+, API resources, Sanctum/Passport, queues, Horizon, Livewire, Inertia, Octane, Pest/PHPUnit, Redis, broadcasting, events/listeners, notifications, task scheduling
[Documentation](https://jeffallan.github.io/claude-skills/skills/backend/laravel-specialist/)
# Async PHP Patterns
## Swoole HTTP Server
```php
<?php
declare(strict_types=1);
use Swoole\HTTP\Server;
use Swoole\HTTP\Request;
use Swoole\HTTP\Response;
$server = new Server('0.0.0.0', 9501);
$server->set([
'worker_num' => 4,
'max_request' => 10000,
'task_worker_num' => 2,
'enable_coroutine' => true,
]);
$server->on('start', function (Server $server) {
echo "Swoole HTTP server started at http://0.0.0.0:9501\n";
});
$server->on('request', function (Request $request, Response $response) {
$response->header('Content-Type', 'application/json');
match ($request->server['request_uri']) {
'/api/users' => handleUsers($request, $response),
'/api/health' => $response->end(json_encode(['status' => 'healthy'])),
default => $response->status(404)->end(json_encode(['error' => 'Not found'])),
};
});
function handleUsers(Request $request, Response $response): void
{
// Coroutine for concurrent DB queries
go(function () use ($response) {
$users = queryDatabase('SELECT * FROM users LIMIT 10');
$response->end(json_encode(['data' => $users]));
});
}
$server->start();
```
## Swoole Coroutines
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Http\Client;
// Concurrent HTTP requests
Coroutine\run(function () {
$results = [];
// Create multiple coroutines
$wg = new Coroutine\WaitGroup();
$urls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments',
];
foreach ($urls as $url) {
$wg->add();
go(function () use ($url, &$results, $wg) {
$client = new Client(parse_url($url, PHP_URL_HOST), 443, true);
$client->set(['timeout' => 5]);
$client->get(parse_url($url, PHP_URL_PATH));
$results[$url] = [
'status' => $client->statusCode,
'body' => $client->body,
];
$client->close();
$wg->done();
});
}
$wg->wait();
print_r($results);
});
```
## Swoole Async MySQL
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\MySQL;
Coroutine\run(function () {
$mysql = new MySQL();
$connected = $mysql->connect([
'host' => '127.0.0.1',
'port' => 3306,
'user' => 'root',
'password' => 'password',
'database' => 'test',
]);
if (!$connected) {
throw new \RuntimeException($mysql->connect_error);
}
// Async query
$result = $mysql->query('SELECT * FROM users WHERE active = 1');
foreach ($result as $row) {
echo "User: {$row['name']}\n";
}
// Prepared statements
$stmt = $mysql->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([42]);
$user = $stmt->fetchAll();
$mysql->close();
});
```
## Swoole Channel (Communication)
```php
<?php
declare(strict_types=1);
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
Coroutine\run(function () {
$channel = new Channel(10); // Buffer size: 10
// Producer
go(function () use ($channel) {
for ($i = 1; $i <= 5; $i++) {
$channel->push("Task {$i}");
echo "Produced: Task {$i}\n";
Coroutine::sleep(0.5);
}
$channel->close();
});
// Consumer
go(function () use ($channel) {
while (true) {
$task = $channel->pop();
if ($task === false && $channel->errCode === SWOOLE_CHANNEL_CLOSED) {
break;
}
echo "Consumed: {$task}\n";
Coroutine::sleep(1);
}
});
});
```
## ReactPHP Event Loop
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\EventLoop\Loop;
use React\Http\Message\Response;
use Psr\Http\Message\ServerRequestInterface;
// HTTP Server
$server = new React\Http\HttpServer(function (ServerRequestInterface $request) {
return new Response(
200,
['Content-Type' => 'application/json'],
json_encode([
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'timestamp' => time(),
])
);
});
$socket = new React\Socket\SocketServer('0.0.0.0:8080');
$server->listen($socket);
echo "Server running at http://0.0.0.0:8080\n";
// Periodic timer
Loop::addPeriodicTimer(5.0, function () {
echo "Heartbeat: " . date('H:i:s') . "\n";
});
// One-time timer
Loop::addTimer(10.0, function () {
echo "This runs once after 10 seconds\n";
});
```
## ReactPHP Async MySQL
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use React\MySQL\Factory;
use React\MySQL\QueryResult;
$factory = new Factory();
$connection = $factory->createLazyConnection('root:password@localhost/database');
$connection->query('SELECT * FROM users WHERE active = 1')
->then(
function (QueryResult $result) {
echo "Found " . count($result->resultRows) . " users\n";
foreach ($result->resultRows as $row) {
echo "User: {$row['name']}\n";
}
},
function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
}
);
// Prepared statements
$connection->query('SELECT * FROM users WHERE id = ?', [42])
->then(function (QueryResult $result) {
$user = $result->resultRows[0] ?? null;
var_dump($user);
});
```
## ReactPHP Promises
```php
<?php
declare(strict_types=1);
use React\Promise\Promise;
use React\Promise\Deferred;
use function React\Promise\all;
// Creating promises
function fetchUser(int $id): Promise
{
$deferred = new Deferred();
// Simulate async operation
Loop::addTimer(1.0, function () use ($deferred, $id) {
$deferred->resolve([
'id' => $id,
'name' => "User {$id}",
]);
});
return $deferred->promise();
}
// Using promises
fetchUser(42)
->then(function ($user) {
echo "Got user: {$user['name']}\n";
return fetchUserPosts($user['id']);
})
->then(function ($posts) {
echo "Got " . count($posts) . " posts\n";
})
->catch(function (\Exception $error) {
echo "Error: " . $error->getMessage() . "\n";
});
// Parallel promises
all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
])->then(function ($users) {
echo "Fetched " . count($users) . " users\n";
});
```
## PHP Fibers (Native PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Simple async function using fibers
function async(callable $callback): Fiber
{
return new Fiber($callback);
}
function await(Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
if ($fiber->isTerminated()) {
return $fiber->getReturn();
}
return $fiber->resume();
}
// Simulate async I/O
function fetchData(string $url): Fiber
{
return async(function () use ($url) {
echo "Fetching: {$url}\n";
Fiber::suspend('pending');
// Simulate network delay
sleep(1);
return "Data from {$url}";
});
}
// Usage
$fiber1 = fetchData('https://api.example.com/users');
$fiber2 = fetchData('https://api.example.com/posts');
await($fiber1);
await($fiber2);
$result1 = await($fiber1);
$result2 = await($fiber2);
echo "{$result1}\n";
echo "{$result2}\n";
```
## Amphp Framework
```php
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Amp\Http\Server\HttpServer;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
use Amp\Http\Server\Router;
use Amp\Socket\Server as SocketServer;
use function Amp\async;
use function Amp\Future\await;
// HTTP Server with Amphp
$router = new Router();
$router->addRoute('GET', '/api/users', function (Request $request): Response {
// Concurrent database queries
$users = await([
async(fn() => queryUsers()),
async(fn() => queryUserStats()),
]);
return new Response(
status: 200,
headers: ['content-type' => 'application/json'],
body: json_encode(['users' => $users[0], 'stats' => $users[1]]),
);
});
$server = new HttpServer(
servers: [SocketServer::listen('0.0.0.0:8080')],
requestHandler: $router,
);
$server->start();
```
## Quick Reference
| Technology | Use Case | Performance |
|------------|----------|-------------|
| Swoole | High-performance servers, WebSockets | Very High |
| ReactPHP | Event-driven apps, real-time | High |
| Amphp | Modern async framework | High |
| Fibers | Native async (PHP 8.1+) | Medium |
| Generators | Simple async patterns | Medium |
| Feature | Swoole | ReactPHP | Amphp |
|---------|--------|----------|-------|
| Coroutines | Yes | No (Promises) | Yes (Fibers) |
| HTTP Server | Built-in | Via package | Via package |
| WebSockets | Built-in | Via package | Via package |
| Extension | Required | Not required | Not required |
| Learning Curve | Medium | Low | Medium |
# Laravel Patterns
## Service Layer Pattern
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTOs\CreateUserData;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private EmailService $emailService,
) {}
public function createUser(CreateUserData $data): User
{
$user = $this->userRepository->create([
'name' => $data->name,
'email' => $data->email,
'password' => Hash::make($data->password),
]);
$this->emailService->sendWelcomeEmail($user);
return $user;
}
public function suspendUser(int $userId, string $reason): void
{
$user = $this->userRepository->findOrFail($userId);
$this->userRepository->update($user->id, [
'status' => UserStatus::SUSPENDED,
'suspension_reason' => $reason,
'suspended_at' => now(),
]);
$this->emailService->sendSuspensionNotice($user, $reason);
}
}
```
## Repository Pattern
```php
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
interface UserRepositoryInterface
{
public function findOrFail(int $id): User;
public function findByEmail(string $email): ?User;
public function create(array $data): User;
public function update(int $id, array $data): User;
public function delete(int $id): void;
public function getActive(): Collection;
}
final class UserRepository implements UserRepositoryInterface
{
public function findOrFail(int $id): User
{
return User::findOrFail($id);
}
public function findByEmail(string $email): ?User
{
return User::where('email', $email)->first();
}
public function create(array $data): User
{
return User::create($data);
}
public function update(int $id, array $data): User
{
$user = $this->findOrFail($id);
$user->update($data);
return $user->fresh();
}
public function delete(int $id): void
{
$this->findOrFail($id)->delete();
}
public function getActive(): Collection
{
return User::where('status', UserStatus::ACTIVE)
->orderBy('created_at', 'desc')
->get();
}
}
```
## Form Requests with Enums
```php
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
final class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create', User::class) ?? false;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', new Enum(UserRole::class)],
'settings' => ['sometimes', 'array'],
'settings.theme' => ['string', Rule::in(['light', 'dark'])],
];
}
public function toDto(): CreateUserData
{
return new CreateUserData(
name: $this->validated('name'),
email: $this->validated('email'),
password: $this->validated('password'),
role: UserRole::from($this->validated('role')),
);
}
}
```
## API Resources
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Models\User
*/
final class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'status' => $this->status->value,
'role' => $this->role->value,
'created_at' => $this->created_at->toIso8601String(),
// Conditional relationships
'posts' => PostResource::collection($this->whenLoaded('posts')),
'profile' => new ProfileResource($this->whenLoaded('profile')),
// Conditional attributes
'is_admin' => $this->when($this->role === UserRole::ADMIN, true),
// Pivot data
'team_role' => $this->whenPivotLoaded('team_user', fn() =>
$this->pivot->role
),
];
}
}
final class UserCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total' => $this->total(),
'per_page' => $this->perPage(),
],
];
}
}
```
## Controllers with DTOs
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateUserRequest;
use App\Http\Resources\UserResource;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class UserController extends Controller
{
public function __construct(
private readonly UserService $userService,
) {}
public function index(): AnonymousResourceCollection
{
$users = User::with('profile')
->where('status', UserStatus::ACTIVE)
->paginate(20);
return UserResource::collection($users);
}
public function store(CreateUserRequest $request): JsonResponse
{
$user = $this->userService->createUser($request->toDto());
return (new UserResource($user))
->response()
->setStatusCode(201);
}
public function show(User $user): UserResource
{
$user->load(['posts', 'profile']);
return new UserResource($user);
}
public function destroy(User $user): JsonResponse
{
$this->authorize('delete', $user);
$this->userService->deleteUser($user->id);
return response()->json(null, 204);
}
}
```
## Jobs & Queues
```php
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
final class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(
private readonly int $userId,
) {}
public function handle(EmailService $emailService): void
{
$user = User::findOrFail($this->userId);
$emailService->sendWelcomeEmail($user);
}
public function failed(\Throwable $exception): void
{
\Log::error('Failed to send welcome email', [
'user_id' => $this->userId,
'error' => $exception->getMessage(),
]);
}
}
// Dispatching jobs
SendWelcomeEmail::dispatch($user->id);
SendWelcomeEmail::dispatch($user->id)->delay(now()->addMinutes(5));
SendWelcomeEmail::dispatch($user->id)->onQueue('emails');
```
## Event Listeners
```php
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final readonly class UserRegistered
{
use Dispatchable, SerializesModels;
public function __construct(
public User $user,
) {}
}
namespace App\Listeners;
use App\Events\UserRegistered;
use App\Jobs\SendWelcomeEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
final class SendWelcomeNotification implements ShouldQueue
{
public function handle(UserRegistered $event): void
{
SendWelcomeEmail::dispatch($event->user->id);
}
}
// In EventServiceProvider
protected $listen = [
UserRegistered::class => [
SendWelcomeNotification::class,
UpdateUserStatistics::class,
],
];
```
## Quick Reference
| Pattern | Purpose | File Location |
|---------|---------|---------------|
| Service | Business logic | `app/Services/` |
| Repository | Data access | `app/Repositories/` |
| Form Request | Validation | `app/Http/Requests/` |
| Resource | API responses | `app/Http/Resources/` |
| Job | Async tasks | `app/Jobs/` |
| Event | Domain events | `app/Events/` |
| DTO | Data transfer | `app/DTOs/` |
| Policy | Authorization | `app/Policies/` |
# Modern PHP 8.3+ Features
## Strict Types & Type Declarations
```php
<?php
declare(strict_types=1);
namespace App\Domain\User;
final readonly class User
{
public function __construct(
public int $id,
public string $email,
public UserStatus $status,
public \DateTimeImmutable $createdAt,
) {}
}
function calculateTotal(int $price, float $taxRate): float
{
return $price * (1 + $taxRate);
}
// Union types
function processId(int|string $id): string
{
return is_int($id) ? (string)$id : $id;
}
// Intersection types
interface Timestamped {}
interface Authenticatable {}
function handleUser(Timestamped&Authenticatable $user): void {}
```
## Enums with Methods
```php
<?php
declare(strict_types=1);
enum UserStatus: string
{
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case DELETED = 'deleted';
public function label(): string
{
return match($this) {
self::ACTIVE => 'Active User',
self::SUSPENDED => 'Suspended',
self::DELETED => 'Deleted User',
};
}
public function canLogin(): bool
{
return $this === self::ACTIVE;
}
public static function fromString(string $value): self
{
return self::from(strtolower($value));
}
}
enum HttpStatus: int
{
case OK = 200;
case CREATED = 201;
case BAD_REQUEST = 400;
case UNAUTHORIZED = 401;
case NOT_FOUND = 404;
case SERVER_ERROR = 500;
public function isSuccess(): bool
{
return $this->value >= 200 && $this->value < 300;
}
}
```
## Readonly Properties & Classes
```php
<?php
declare(strict_types=1);
// Readonly class (PHP 8.2+)
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
// Individual readonly properties
class Configuration
{
public function __construct(
public readonly string $apiKey,
public readonly string $apiSecret,
private string $cache = '',
) {}
}
```
## Attributes (Metadata)
```php
<?php
declare(strict_types=1);
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class Route
{
public function __construct(
public string $path,
public string $method = 'GET',
public array $middleware = [],
) {}
}
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final readonly class Validate
{
public function __construct(
public ?string $rule = null,
public ?int $min = null,
public ?int $max = null,
) {}
}
// Using attributes
#[Route('/api/users', method: 'POST', middleware: ['auth'])]
final class CreateUserController
{
public function __invoke(CreateUserRequest $request): JsonResponse
{
// ...
}
}
class UserDto
{
#[Validate(rule: 'email')]
public string $email;
#[Validate(min: 8, max: 100)]
public string $password;
}
```
## First-Class Callables
```php
<?php
declare(strict_types=1);
class UserService
{
public function findById(int $id): ?User {}
public function create(array $data): User {}
}
$service = new UserService();
// PHP 8.1+ first-class callable syntax
$finder = $service->findById(...);
$user = $finder(42);
// Array operations
$numbers = [1, 2, 3, 4, 5];
$doubled = array_map(fn($n) => $n * 2, $numbers);
// Named arguments with callable
$result = array_filter(
array: $numbers,
callback: fn($n) => $n % 2 === 0,
);
```
## Match Expressions
```php
<?php
declare(strict_types=1);
function getStatusColor(UserStatus $status): string
{
return match ($status) {
UserStatus::ACTIVE => 'green',
UserStatus::SUSPENDED => 'yellow',
UserStatus::DELETED => 'red',
};
}
function calculateShipping(int $weight, string $zone): float
{
return match (true) {
$weight < 1000 => 5.00,
$weight < 5000 && $zone === 'local' => 10.00,
$weight < 5000 => 15.00,
default => 25.00,
};
}
// Match with multiple conditions
function getHttpMessage(int $code): string
{
return match ($code) {
200, 201, 204 => 'Success',
400, 422 => 'Client Error',
401, 403 => 'Unauthorized',
500, 502, 503 => 'Server Error',
default => 'Unknown',
};
}
```
## Fibers (PHP 8.1+)
```php
<?php
declare(strict_types=1);
// Basic fiber example
$fiber = new \Fiber(function (): void {
$value = \Fiber::suspend('fiber started');
echo "Received: {$value}\n";
\Fiber::suspend('second suspend');
echo "Fiber completed\n";
});
$result1 = $fiber->start();
echo "First result: {$result1}\n";
$result2 = $fiber->resume('data from main');
echo "Second result: {$result2}\n";
$fiber->resume('final data');
// Async-style with fibers
function async(callable $callback): \Fiber
{
return new \Fiber($callback);
}
function await(\Fiber $fiber): mixed
{
if (!$fiber->isStarted()) {
return $fiber->start();
}
return $fiber->resume();
}
```
## Never Type
```php
<?php
declare(strict_types=1);
function redirect(string $url): never
{
header("Location: {$url}");
exit;
}
function abort(int $code, string $message): never
{
http_response_code($code);
echo json_encode(['error' => $message]);
exit;
}
class NotFoundException extends \Exception
{
public static function throw(string $resource): never
{
throw new self("Resource not found: {$resource}");
}
}
```
## Quick Reference
| Feature | PHP Version | Usage |
|---------|-------------|-------|
| Readonly properties | 8.1+ | `public readonly string $name` |
| Readonly classes | 8.2+ | `readonly class User {}` |
| Enums | 8.1+ | `enum Status: string {}` |
| First-class callables | 8.1+ | `$fn = $obj->method(...)` |
| Never type | 8.1+ | `function exit(): never` |
| Fibers | 8.1+ | `new \Fiber(fn() => ...)` |
| Pure intersection types | 8.1+ | `A&B $param` |
| DNF types | 8.2+ | `(A&B)\|C $param` |
| Constants in traits | 8.2+ | `trait T { const X = 1; }` |
# Symfony Patterns
## Dependency Injection
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Repository\UserRepositoryInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserService
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public function createUser(string $email, string $password): User
{
$user = new User($email, password_hash($password, PASSWORD_ARGON2ID));
$this->userRepository->save($user);
$this->logger->info('User created', ['email' => $email]);
return $user;
}
}
```
## Service Configuration (services.yaml)
```yaml
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $projectDir: '%kernel.project_dir%'
bool $isDebug: '%kernel.debug%'
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Interface binding
App\Repository\UserRepositoryInterface:
class: App\Repository\DoctrineUserRepository
# Service with specific configuration
App\Service\PaymentService:
arguments:
$apiKey: '%env(PAYMENT_API_KEY)%'
$timeout: 30
# Tagged services
App\EventSubscriber\:
resource: '../src/EventSubscriber/'
tags: ['kernel.event_subscriber']
```
## Controllers with Attributes
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\DTO\CreateUserRequest;
use App\Entity\User;
use App\Service\UserService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/users', name: 'api_users_')]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserService $userService,
) {}
#[Route('', name: 'list', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function list(): JsonResponse
{
$users = $this->userService->getAllUsers();
return $this->json($users, Response::HTTP_OK, [], [
'groups' => ['user:read'],
]);
}
#[Route('', name: 'create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(
#[MapRequestPayload] CreateUserRequest $request
): JsonResponse {
$user = $this->userService->createUser(
$request->email,
$request->password
);
return $this->json($user, Response::HTTP_CREATED, [], [
'groups' => ['user:read'],
]);
}
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(User $user): JsonResponse
{
$this->denyAccessUnlessGranted('view', $user);
return $this->json($user, context: ['groups' => ['user:detail']]);
}
}
```
## DTOs with Validation
```php
<?php
declare(strict_types=1);
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateUserRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8, max: 100)]
#[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)]
public string $password,
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name,
#[Assert\Choice(choices: ['admin', 'user', 'moderator'])]
public string $role = 'user',
) {}
}
final readonly class UpdateUserRequest
{
public function __construct(
#[Assert\Email]
public ?string $email = null,
#[Assert\Length(min: 2, max: 100)]
public ?string $name = null,
#[Assert\Type('bool')]
public ?bool $isActive = null,
) {}
}
```
## Event Subscribers
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\UserRegisteredEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
final readonly class UserSubscriber implements EventSubscriberInterface
{
public function __construct(
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
UserRegisteredEvent::class => [
['sendWelcomeEmail', 10],
['logRegistration', 5],
],
];
}
public function sendWelcomeEmail(UserRegisteredEvent $event): void
{
$user = $event->getUser();
// Send email logic
$this->logger->info('Welcome email sent', ['user_id' => $user->getId()]);
}
public function logRegistration(UserRegisteredEvent $event): void
{
$this->logger->info('User registered', [
'user_id' => $event->getUser()->getId(),
'email' => $event->getUser()->getEmail(),
]);
}
}
```
## Custom Events
```php
<?php
declare(strict_types=1);
namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class UserRegisteredEvent extends Event
{
public function __construct(
private readonly User $user,
private readonly \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
public function getUser(): User
{
return $this->user;
}
public function getOccurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
}
// Dispatching events
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
final readonly class UserService
{
public function __construct(
private EventDispatcherInterface $eventDispatcher,
) {}
public function registerUser(string $email, string $password): User
{
$user = new User($email, $password);
// ... save user
$this->eventDispatcher->dispatch(new UserRegisteredEvent($user));
return $user;
}
}
```
## Console Commands
```php
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:user:create',
description: 'Create a new user',
)]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserService $userService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('email', InputArgument::REQUIRED, 'User email')
->addArgument('password', InputArgument::REQUIRED, 'User password')
->addOption('admin', 'a', InputOption::VALUE_NONE, 'Make user admin');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$password = $input->getArgument('password');
$isAdmin = $input->getOption('admin');
$user = $this->userService->createUser($email, $password, $isAdmin);
$io->success(sprintf('User created with ID: %d', $user->getId()));
return Command::SUCCESS;
}
}
```
## Voters (Authorization)
```php
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Post;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class PostVoter extends Voter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Post;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Post $post */
$post = $subject;
return match ($attribute) {
self::VIEW => $this->canView($post, $user),
self::EDIT => $this->canEdit($post, $user),
self::DELETE => $this->canDelete($post, $user),
default => false,
};
}
private function canView(Post $post, User $user): bool
{
return $post->isPublished() || $this->isOwner($post, $user);
}
private function canEdit(Post $post, User $user): bool
{
return $this->isOwner($post, $user);
}
private function canDelete(Post $post, User $user): bool
{
return $this->isOwner($post, $user) || $user->hasRole('ROLE_ADMIN');
}
private function isOwner(Post $post, User $user): bool
{
return $post->getAuthor()->getId() === $user->getId();
}
}
```
## Message Handler (Messenger)
```php
<?php
declare(strict_types=1);
namespace App\Message;
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
) {}
}
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepositoryInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class SendWelcomeEmailHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return;
}
// Send email logic
}
}
// Dispatching messages
use Symfony\Component\Messenger\MessageBusInterface;
$this->messageBus->dispatch(new SendWelcomeEmail($user->getId()));
```
## Quick Reference
| Component | Purpose | File Location |
|-----------|---------|---------------|
| Controller | HTTP handlers | `src/Controller/` |
| Service | Business logic | `src/Service/` |
| Repository | Data access | `src/Repository/` |
| Event | Domain events | `src/Event/` |
| EventSubscriber | Event handlers | `src/EventSubscriber/` |
| Command | CLI commands | `src/Command/` |
| Voter | Authorization | `src/Security/Voter/` |
| Message | Async messages | `src/Message/` |
| MessageHandler | Message handlers | `src/MessageHandler/` |
| DTO | Data transfer | `src/DTO/` |
# Testing & Quality Assurance
## PHPUnit with Strict Types
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepositoryInterface;
use App\Service\UserService;
use App\Service\EmailService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $userRepository;
private EmailService&MockObject $emailService;
private UserService $userService;
protected function setUp(): void
{
$this->userRepository = $this->createMock(UserRepositoryInterface::class);
$this->emailService = $this->createMock(EmailService::class);
$this->userService = new UserService(
$this->userRepository,
$this->emailService
);
}
public function testCreateUserSuccessfully(): void
{
$email = 'test@example.com';
$password = 'SecurePass123!';
$this->userRepository
->expects($this->once())
->method('findByEmail')
->with($email)
->willReturn(null);
$this->userRepository
->expects($this->once())
->method('create')
->willReturn($this->createUser($email));
$this->emailService
->expects($this->once())
->method('sendWelcomeEmail');
$user = $this->userService->createUser($email, $password);
$this->assertSame($email, $user->email);
}
public function testCreateUserThrowsExceptionWhenEmailExists(): void
{
$this->expectException(\DomainException::class);
$this->expectExceptionMessage('Email already exists');
$this->userRepository
->method('findByEmail')
->willReturn($this->createUser('test@example.com'));
$this->userService->createUser('test@example.com', 'password');
}
private function createUser(string $email): User
{
return new User(
id: 1,
email: $email,
password: password_hash('password', PASSWORD_ARGON2ID),
);
}
}
```
## Data Providers
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Validator;
use App\Validator\EmailValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EmailValidatorTest extends TestCase
{
#[Test]
#[DataProvider('validEmailProvider')]
public function itValidatesCorrectEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid($email));
}
#[Test]
#[DataProvider('invalidEmailProvider')]
public function itRejectsInvalidEmails(string $email): void
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid($email));
}
public static function validEmailProvider(): array
{
return [
['user@example.com'],
['john.doe@company.co.uk'],
['test+filter@domain.org'],
];
}
public static function invalidEmailProvider(): array
{
return [
['invalid'],
['@example.com'],
['user@'],
['user space@example.com'],
];
}
}
```
## Laravel Feature Tests
```php
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
final class UserControllerTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function testUserCanViewTheirProfile(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/api/users/me');
$response->assertOk()
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
],
]);
}
public function testUserCanUpdateTheirProfile(): void
{
$user = User::factory()->create();
$newName = $this->faker->name();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'name' => $newName,
]);
$response->assertOk();
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => $newName,
]);
}
public function testUnauthorizedUserCannotAccessProfile(): void
{
$response = $this->getJson('/api/users/me');
$response->assertUnauthorized();
}
public function testValidationFailsWithInvalidData(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->putJson('/api/users/me', [
'email' => 'not-an-email',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
}
}
```
## Pest Testing (Modern Alternative)
```php
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\UserService;
beforeEach(function () {
$this->userService = app(UserService::class);
});
it('creates a user successfully', function () {
$user = $this->userService->createUser(
email: 'test@example.com',
password: 'SecurePass123!'
);
expect($user)
->toBeInstanceOf(User::class)
->email->toBe('test@example.com');
});
it('validates email format', function (string $email, bool $valid) {
$validator = new EmailValidator();
expect($validator->isValid($email))->toBe($valid);
})->with([
['test@example.com', true],
['invalid', false],
['@example.com', false],
]);
test('authenticated user can view profile', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/api/users/me')
->assertOk()
->assertJson(['data' => ['email' => $user->email]]);
});
test('guest cannot access protected routes', function () {
$this->getJson('/api/users/me')
->assertUnauthorized();
});
```
## PHPStan Configuration
```neon
# phpstan.neon
parameters:
level: 9
paths:
- src
- tests
excludePaths:
- src/bootstrap.php
- vendor
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
tmpDir: var/cache/phpstan
ignoreErrors:
# Ignore specific Laravel magic
- '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
type_coverage:
return_type: 100
param_type: 100
property_type: 100
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
```
## PHPStan Annotations
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<User>
*/
final class UserRepository extends EntityRepository
{
/**
* @return User[]
*/
public function findActive(): array
{
return $this->createQueryBuilder('u')
->where('u.status = :status')
->setParameter('status', 'active')
->getQuery()
->getResult();
}
/**
* @param int[] $ids
* @return User[]
*/
public function findByIds(array $ids): array
{
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
}
/**
* @template T
*/
final readonly class Result
{
/**
* @param T $data
*/
public function __construct(
public mixed $data,
public bool $success,
) {}
/**
* @return T
*/
public function getData(): mixed
{
return $this->data;
}
}
```
## Mockery (Advanced Mocking)
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Repository\UserRepository;
use App\Service\NotificationService;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
final class NotificationServiceTest extends TestCase
{
use MockeryPHPUnitIntegration;
public function testSendsNotificationToActiveUsers(): void
{
$repository = Mockery::mock(UserRepository::class);
$repository->shouldReceive('findActive')
->once()
->andReturn([
$this->createUser('user1@example.com'),
$this->createUser('user2@example.com'),
]);
$service = new NotificationService($repository);
$result = $service->notifyActiveUsers('Important message');
$this->assertSame(2, $result->count());
}
public function testHandlesEmailServiceFailure(): void
{
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldReceive('send')
->once()
->andThrow(new \RuntimeException('Email service down'));
$service = new NotificationService($emailService);
$this->expectException(\RuntimeException::class);
$service->sendNotification('test@example.com', 'Hello');
}
private function createUser(string $email): User
{
return new User(id: 1, email: $email, password: 'hashed');
}
}
```
## Code Coverage
```xml
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/bootstrap</directory>
<file>src/Kernel.php</file>
</exclude>
<report>
<html outputDirectory="coverage/html"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
```
## Quick Reference
| Tool | Purpose | Command |
|------|---------|---------|
| PHPUnit | Unit/Feature tests | `./vendor/bin/phpunit` |
| Pest | Modern testing | `./vendor/bin/pest` |
| PHPStan | Static analysis | `./vendor/bin/phpstan analyse` |
| Psalm | Alternative static analysis | `./vendor/bin/psalm` |
| PHP-CS-Fixer | Code style | `./vendor/bin/php-cs-fixer fix` |
| PHPMD | Mess detector | `./vendor/bin/phpmd src text cleancode` |
| Assertion | PHPUnit | Pest |
|-----------|---------|------|
| Equality | `$this->assertSame()` | `expect()->toBe()` |
| Type | `$this->assertInstanceOf()` | `expect()->toBeInstanceOf()` |
| Array | `$this->assertContains()` | `expect()->toContain()` |
| Exception | `$this->expectException()` | `expect()->toThrow()` |
| Count | `$this->assertCount()` | `expect()->toHaveCount()` |
---
name: php-pro
description: Use when building PHP applications with modern PHP 8.3+ features, Laravel, or Symfony frameworks. Invokes strict typing, PHPStan level 9, async patterns with Swoole, and PSR standards. Creates controllers, configures middleware, generates migrations, writes PHPUnit/Pest tests, defines typed DTOs and value objects, sets up dependency injection, and scaffolds REST/GraphQL APIs. Use when working with Eloquent, Doctrine, Composer, Psalm, ReactPHP, or any PHP API development.
license: MIT
metadata:
author: https://github.com/Jeffallan
version: "1.1.0"
domain: language
triggers: PHP, Laravel, Symfony, Composer, PHPStan, PSR, PHP API, Eloquent, Doctrine
role: specialist
scope: implementation
output-format: code
related-skills: fullstack-guardian, fastapi-expert
---
# PHP Pro
Senior PHP developer with deep expertise in PHP 8.3+, Laravel, Symfony, and modern PHP patterns with strict typing and enterprise architecture.
## Core Workflow
1. **Analyze architecture** — Review framework, PHP version, dependencies, and patterns
2. **Design models** — Create typed domain models, value objects, DTOs
3. **Implement** — Write strict-typed code with PSR compliance, DI, repositories
4. **Secure** — Add validation, authentication, XSS/SQL injection protection
5. **Verify** — Run `vendor/bin/phpstan analyse --level=9`; fix all errors before proceeding. Run `vendor/bin/phpunit` or `vendor/bin/pest`; enforce 80%+ coverage. Only deliver when both pass clean.
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Modern PHP | `references/modern-php-features.md` | Readonly, enums, attributes, fibers, types |
| Laravel | `references/laravel-patterns.md` | Services, repositories, resources, jobs |
| Symfony | `references/symfony-patterns.md` | DI, events, commands, voters |
| Async PHP | `references/async-patterns.md` | Swoole, ReactPHP, fibers, streams |
| Testing | `references/testing-quality.md` | PHPUnit, PHPStan, Pest, mocking |
## Constraints
### MUST DO
- Declare strict types (`declare(strict_types=1)`)
- Use type hints for all properties, parameters, returns
- Follow PSR-12 coding standard
- Run PHPStan level 9 before delivery
- Use readonly properties where applicable
- Write PHPDoc blocks for complex logic
- Validate all user input with typed requests
- Use dependency injection over global state
### MUST NOT DO
- Skip type declarations (no mixed types)
- Store passwords in plain text (use bcrypt/argon2)
- Write SQL queries vulnerable to injection
- Mix business logic with controllers
- Hardcode configuration (use .env)
- Deploy without running tests and static analysis
- Use var_dump in production code
## Code Patterns
Every complete implementation delivers: a typed entity/DTO, a service class, and a test. Use these as the baseline structure.
### Readonly DTO / Value Object
```php
<?php
declare(strict_types=1);
namespace App\DTO;
final readonly class CreateUserDTO
{
public function __construct(
public string $name,
public string $email,
public string $password,
) {}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
email: $data['email'],
password: $data['password'],
);
}
}
```
### Typed Service with Constructor DI
```php
<?php
declare(strict_types=1);
namespace App\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
final class UserService
{
public function __construct(
private readonly UserRepositoryInterface $users,
) {}
public function create(CreateUserDTO $dto): User
{
return $this->users->create([
'name' => $dto->name,
'email' => $dto->email,
'password' => Hash::make($dto->password),
]);
}
}
```
### PHPUnit Test Structure
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\DTO\CreateUserDTO;
use App\Models\User;
use App\Repositories\UserRepositoryInterface;
use App\Services\UserService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class UserServiceTest extends TestCase
{
private UserRepositoryInterface&MockObject $users;
private UserService $service;
protected function setUp(): void
{
parent::setUp();
$this->users = $this->createMock(UserRepositoryInterface::class);
$this->service = new UserService($this->users);
}
public function testCreateHashesPassword(): void
{
$dto = new CreateUserDTO('Alice', 'alice@example.com', 'secret');
$user = new User(['name' => 'Alice', 'email' => 'alice@example.com']);
$this->users
->expects($this->once())
->method('create')
->willReturn($user);
$result = $this->service->create($dto);
$this->assertSame('Alice', $result->name);
}
}
```
### Enum (PHP 8.1+)
```php
<?php
declare(strict_types=1);
namespace App\Enums;
enum UserStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Banned = 'banned';
public function label(): string
{
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Banned => 'Banned',
};
}
}
```
## Output Templates
When implementing a feature, deliver in this order:
1. Domain models (entities, value objects, enums)
2. Service/repository classes
3. Controller/API endpoints
4. Test files (PHPUnit/Pest)
5. Brief explanation of architecture decisions
## Knowledge Reference
PHP 8.3+, Laravel 11, Symfony 7, Composer, PHPStan, Psalm, PHPUnit, Pest, Eloquent ORM, Doctrine, PSR standards, Swoole, ReactPHP, Redis, MySQL/PostgreSQL, REST/GraphQL APIs
[Documentation](https://jeffallan.github.io/claude-skills/skills/language/php-pro/)
+1
-1
{
"name": "cc-codeconductor",
"version": "0.2.10",
"version": "0.3.0",
"description": "A multi-agent orchestration framework for AI-assisted software engineering workflows.",

@@ -5,0 +5,0 @@ "keywords": [

@@ -354,2 +354,13 @@ <!-- CODECONDUCTOR:BEGIN managed -->

---
## Loop Agent & Monorepos
### Loop Agent Mode (Intense Workflows)
- If tests fail, run the cycle: Implementer (applies fix) -> Tester (verifies suite) up to 3 times.
- If still failing, stop and report back with findings.
### Monorepo Workspaces
- Focus operations strictly within the specified sub-package or workspace directory in the Task Card scope. Do not modify files or run commands outside this package directory.
<!-- CODECONDUCTOR:END managed -->

@@ -567,2 +567,23 @@ # CodeConductor — Multi-Agent Orchestration Framework

When the active task touches Next.js components, Server Actions, or App Router layouts,
apply `.claude/skills/nextjs-typescript/SKILL.md`.
When the active task touches Astro pages or configuration,
apply `.claude/skills/astro/SKILL.md`.
When the active task touches Android project files (Kotlin, Compose, Media3, Gradle),
apply `.claude/skills/android/SKILL.md`.
When the active task touches Laravel project files (Eloquent, blade, livewire, controllers, routes),
apply `.claude/skills/laravel-specialist/SKILL.md`.
When the active task touches PHP project files (PHPUnit, Composer, syntax, types),
apply `.claude/skills/php-pro/SKILL.md`.
When the active task touches backend security, authorization, or data verification,
apply `.claude/skills/security/SKILL.md`.
When the active task requires debugging frontend interactions or accessibility (a11y) checks,
apply `.claude/skills/a11y-debugging/SKILL.md` and `.claude/skills/modern-web-guidance/SKILL.md`.
When the user asks to create a Spring Boot feature (entity, service, controller,

@@ -581,2 +602,15 @@ or tests), apply `.claude/skills/spring-boot-feature/SKILL.md`.

## Teammate & Loop Agent Rules
### Teammate delegation (Presets supporting multi-teams)
- Utilize parallel sub-agent execution by enabling `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` in `settings.json`.
- Allocate tasks to specific teammates: use `sonnet` for Orchestrator and Architect, and delegate secondary tasks (e.g. testing, review, docs) to `haiku` to optimize token budgets.
### Loop Agent Mode (Intense Workflows)
- If tests or verifications fail, do not stop. Re-route the failure logs back to the Implementer teammate.
- The workflow iterates: Implementer (applies fix) -> Tester (verifies suite).
- Permit up to 3 self-correction iterations. If tests still fail, halt and escalate to the human with diagnostic details.
---
## What Never Changes

@@ -583,0 +617,0 @@

@@ -822,2 +822,9 @@ <!-- CODECONDUCTOR:BEGIN managed -->

| `sqlalchemy` | SQLAlchemy models, sessions, Alembic migrations |
| `nextjs-typescript` | Next.js components, Server Actions, App Router |
| `astro` | Astro pages, islands, content collections |
| `android` | Android Kotlin, Jetpack Compose, Media3 components |
| `laravel-specialist` | Laravel models, routes, API resources, Livewire |
| `php-pro` | PHPStan static analysis, modern PHP 8.3+ features |
| `security` | Backend security, authorization, data validation |
| `a11y-debugging` | Debugging frontend accessibility and web standards |

@@ -835,2 +842,14 @@ To activate a skill, include in your request:

## Loop Agent & Monorepo Workspaces
### Loop Agent Mode (Intense Workflows)
- If tests fail during verification, route the logs back to the Implementer.
- Run the cycle: Implementer (applies fix) -> Tester (verifies suite) up to 3 times.
- If still failing, stop and report back with findings.
### Monorepo Workspaces
- Note that this is a monorepo. Focus operations strictly within the specified sub-package or workspace directory in the Task Card scope. Avoid modifying files or running commands outside this package directory.
---
## Scorecard Format

@@ -837,0 +856,0 @@

@@ -145,3 +145,99 @@ ---

| `build.gradle.kts` + `org.springframework.boot` | Spring Boot + Kotlin |
| `next.config.js` / `next.config.mjs` / `next.config.ts` | Next.js |
| `requirements.txt` / `pyproject.toml` with `fastapi` | FastAPI |
| `pnpm-workspace.yaml` / `go.work` | Monorepo Workspace |
| `go.mod` / `Cargo.toml` without django/fastapi | Generic Backend |
| `index.html` / react/vue dependencies | Generic Frontend |
| `AndroidManifest.xml` present | Android |
| `artisan` present | Laravel |
| `composer.json` or `*.php` present | PHP |
### Next.js
When a Next.js project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `nextjs-typescript` skill before designing." |
| `implementer` | "Invoke `nextjs-typescript` before writing any code." |
| `tester` | "Invoke `testing-tdd` and write Next.js unit and integration tests (using Vitest or Playwright)." |
| `reviewer` | "Invoke the `nextjs-typescript` skill to check component boundaries (RSC vs RCC) and validation rules." |
### FastAPI
When a FastAPI project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `python-fastapi-stack` and `sqlalchemy` skills before designing." |
| `implementer` | "Invoke `python-fastapi-stack` and `sqlalchemy` before writing any code." |
| `tester` | "Invoke Python testing guidelines to write FastAPI endpoint contract tests." |
| `reviewer` | "Invoke `python` to verify FastAPI routers and SQLAlchemy async patterns." |
### Generic Backend
When a generic backend project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `security` skill to review backend boundaries, authentication schemes, and data validation rules." |
| `implementer` | "Invoke `security` to ensure inputs are validated, parameterized queries are used, and secrets are not exposed." |
| `reviewer` | "Invoke `security` to check for injection vulnerabilities, resource leaks, and lack of authorization checks." |
### Android
When an Android project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `android` skill before designing." |
| `implementer` | "Invoke `android` before writing any code." |
| `tester` | "Invoke the `android` skill to write unit or instrumentation tests (JUnit 5, MockK, Espresso, Compose UI Testing)." |
| `reviewer` | "Invoke the `android` skill to verify Jetpack Compose components stability, ExoPlayer resource cleanup, and Kotlin Coroutines/Flows dispatchers." |
### Laravel
When a Laravel project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `laravel-specialist` and `php-pro` skills before designing." |
| `implementer` | "Invoke `laravel-specialist` and `php-pro` before writing any code." |
| `tester` | "Invoke the `laravel-specialist` skill to write Pest/PHPUnit tests for Laravel features." |
| `reviewer` | "Invoke the `laravel-specialist` skill to verify Eloquent queries, Sanctum authentication, and Livewire components." |
### PHP
When a PHP project is detected, include the following skill invocation instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `php-pro` skill before designing." |
| `implementer` | "Invoke `php-pro` before writing any code." |
| `tester` | "Invoke PHP testing and quality assurance guidelines in the `php-pro` skill." |
| `reviewer` | "Invoke the `php-pro` skill to analyze strict typing, PHPStan level 9 violations, and PSR standards." |
### Generic Frontend
When a generic frontend project is detected, include the following skill invocation
instruction in the delegation message for each agent:
| Delegated agent | Instruction to include in delegation |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `architect` | "Invoke the `security` skill and accessibility guidelines to plan keyboard navigation and semantic HTML structures." |
| `implementer` | "Invoke `modern-web-guidance` and accessibility rules to implement semantically clean and keyboard-accessible UI." |
| `tester` | "Invoke `a11y-debugging` to verify focus handling, tab order, and screen reader labels." |
| `reviewer` | "Verify compliance with frontend security standards and web accessibility guidelines." |
### Monorepo Workspaces
When a monorepo workspace signal is present, include this instruction for ALL agents:
> "This is a monorepo. Focus all file reads, edits, and commands strictly within
> the sub-package or workspace directory specified in the Task Card scope. Avoid
> modifying files or running commands outside this package's directory."
### Python / Django / PostgreSQL

@@ -159,3 +255,3 @@

**TDD gate for medium and high risk Python tasks:**
**TDD gate for medium and high risk Python/Backend tasks:**

@@ -181,2 +277,26 @@ For tasks classified medium or high, modify the agent sequence to enforce

## Intense Workflow — Loop Agent Mode
For high-complexity tasks, or when verification tests fail, the orchestrator
routes the agents through an iterative feedback loop:
1. **Cycle**: Implementer -> Tester -> Orchestrator validation.
2. If the `tester` reports failing tests:
- Route back to `implementer` with the specific test failures.
- Instruct the implementer to make target adjustments to resolve the failures.
3. This cycle repeats up to 3 times. If tests are still failing after the 3rd iteration, escalate to the human with a full diagnostics summary.
---
## Multi-Team / Teammate Delegation
When the preset target supports multi-team execution (e.g. Claude Code with
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` enabled):
1. Spawn parallel teammates (`tester`, `reviewer`, etc.) to run verification and checks concurrently when possible.
2. Assign the most cost-efficient models for secondary roles:
- Primary Orchestrator / Architect: `sonnet` / `pro` (maximum context / reasoning).
- Task Coach, Docs, Repo Explorer, Reviewer: `haiku` / `flash` (fast, cost-effective).
---
## Routing documentation

@@ -183,0 +303,0 @@

@@ -126,2 +126,33 @@ ---

## Stricter Stack-Specific Checklist
Apply these detailed checks based on the detected stack:
### Next.js
- [ ] RSC vs RCC boundary: Client directives (`"use client"`) are only placed on interactive leaf node files, not on static layouts/pages.
- [ ] Server Actions input: Every Server Action validates `FormData` or arguments using a schema library (like Zod) before performing mutations. No raw data is trusted.
- [ ] Browser APIs: Window, document, and localStorage access are guarded (e.g. `typeof window !== 'undefined'`) or only run inside `useEffect`.
### FastAPI
- [ ] Request Typing: All endpoints use typed Pydantic models (v2) for request bodies and path/query parameters.
- [ ] Dependency Injection: Middleware, databases, and services are injected cleanly using FastAPI `Depends`.
### Generic Backend
- [ ] No SQL Injection: Database queries use parameterized placeholders or proper ORM queries; string concatenation or template literals for SQL are block-worthy.
- [ ] Resource management: Connections, files, sockets, and sessions are closed explicitly or via context managers (e.g. `with` block).
### Generic Frontend
- [ ] Keyboard accessibility: All interactive elements are focusable (using `button`, `a`, or explicit `tabindex="0"`) and react to both click and keydown (Enter/Space) events.
- [ ] ARIA & alt text: All images have descriptive `alt` attributes. Form fields have corresponding `<label>` or `aria-label` tags.
- [ ] Semantic HTML: Page structures use semantic landmarks (`<main>`, `<header>`, `<footer>`, `<nav>`, `<article>`, `<section>`).
### Android
- [ ] Jetpack Compose Stability: Ensure all custom state model classes passed to Composables are immutable (annotated with `@Immutable` or `@Stable`) to prevent unnecessary recompositions.
- [ ] ExoPlayer / Media3 Resource Management: Verify that ExoPlayer or Media3 player instances are properly cleaned up and released (e.g. in `onDestroy` or when the service is stopped) to prevent resource/memory leaks.
- [ ] Coroutine Dispatchers: Ensure Coroutines are launched using injected dispatchers rather than hardcoding `Dispatchers.IO` or `Dispatchers.Default` directly in ViewModels or domain/data service classes.
- [ ] Battery & Wake Locks: Verify that Wake Locks are managed carefully and released when playback is paused or stopped to prevent draining the user's battery.
### Monorepo Workspaces
- [ ] Workspace boundary: No relative imports escape a workspace package root to reference another package's files directly. Inter-package imports must resolve through configured workspace dependencies.
## What You Never Do

@@ -128,0 +159,0 @@

@@ -40,5 +40,6 @@ target: agy

template: true
- src: opencode/prompts/v0.2.0
dest: .agents/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .agents/prompts/v0.3.0
strategy: overwrite
template: true

@@ -26,4 +26,5 @@ target: claude

template: true
- src: opencode/prompts/v0.2.0
dest: .claude/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .claude/prompts/v0.3.0
strategy: overwrite
template: true

@@ -10,4 +10,5 @@ target: codex

strategy: overwrite
- src: opencode/prompts/v0.2.0
dest: .codex/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .codex/prompts/v0.3.0
strategy: overwrite
template: true

@@ -7,4 +7,5 @@ target: cursor

template: true
- src: opencode/prompts/v0.2.0
dest: .cursor/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .cursor/prompts/v0.3.0
strategy: overwrite
template: true

@@ -7,4 +7,5 @@ target: gemini

template: true
- src: opencode/prompts/v0.2.0
dest: .gemini/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .gemini/prompts/v0.3.0
strategy: overwrite
template: true

@@ -18,7 +18,8 @@ target: opencode

strategy: overwrite
- src: opencode/prompts/v0.2.0
dest: .opencode/prompts/v0.2.0
- src: opencode/prompts/v0.3.0
dest: .opencode/prompts/v0.3.0
strategy: overwrite
template: true
- src: opencode/skills
dest: .opencode/skills
strategy: overwrite

@@ -14,3 +14,3 @@ # Model configuration for OpenCode preset

claude: claude-sonnet-4-6
opencode: opencode-go/mimo-v2.5-pro
opencode: opencode-go/mimo-v2.5
codex: gpt-5.3-codex

@@ -21,3 +21,3 @@ gemini: gemini-2.5-flash

claude: claude-sonnet-4-6
opencode: opencode-go/minimax-m2.7
opencode: opencode-go/minimax-m3
codex: gpt-5.3-codex

@@ -34,3 +34,3 @@ gemini: gemini-2.5-flash

claude: claude-sonnet-4-6
opencode: opencode-go/qwen3.6-plus
opencode: opencode-go/qwen3.7-plus
codex: gpt-5.4

@@ -41,3 +41,3 @@ gemini: gemini-2.5-pro

claude: claude-haiku-4-5-20251001
opencode: opencode-go/qwen3.6-plus
opencode: opencode-go/qwen3.7-plus
codex: gpt-5.4-mini

@@ -48,3 +48,3 @@ gemini: gemini-2.5-flash

claude: claude-haiku-4-5-20251001
opencode: opencode-go/kimi-k2.6
opencode: opencode-go/kimi-k2.7-code
codex: gpt-5.4-mini

@@ -55,3 +55,3 @@ gemini: gemini-2.5-flash

claude: claude-haiku-4-5-20251001
opencode: opencode-go/qwen3.6-plus
opencode: opencode-go/deepseek-v4-flash
codex: gpt-5.4-mini

@@ -58,0 +58,0 @@ gemini: gemini-2.5-flash

Sorry, the diff of this file is too big to display