cc-codeconductor
Advanced tools
| --- | ||
| 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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2234968
23.99%236
24.87%20502
1.15%