Author: Daryle De Silva

  • The Power of @extends in Laravel Factories

    When you generate a new factory in recent Laravel versions, you’ll notice a docblock like this above the class:

    /**
     * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
     */

    It’s tempting to ignore it as boilerplate, but it serves a critical purpose for your Developer Experience (DX). This @extends tag tells your IDE (PHPStorm, VS Code) exactly which model this factory is responsible for.

    By defining this relationship, your IDE can:

    • Provide auto-complete for model attributes inside the definition() method.
    • Correctly type-hint the return values when calling factory() on the model.
    • Identify missing or misspelled attributes before you even run your tests.

    Pro Tip: If you’re working on an older Laravel project that doesn’t have these docblocks, add them manually! It’s a quick way to reduce “red squiggles” and speed up your test writing.

  • Why reach for the DB facade in a migration?

    Migrations aren’t only for schema changes. Sometimes a migration also needs to reshape existing data, like backfilling a newly-created column or renaming a value across every row.

    When you touch data in a migration, you have a choice: use the Eloquent model or use the DB facade with a table name.

    For plain data changes inside a migration, the DB facade is usually the better choice. Here’s why:

    1. Stability

    Models don’t always live forever. You might rename them, move them to a different namespace, or swap out a package that defines them. When that happens, any migration referencing that model breaks retroactively, even if it ran fine the day you wrote it.

    The table name is a much more stable contract. Binding the migration to the table keeps it working even as your application code evolves.

    2. Side Effects

    Migrations live at the database layer, not the domain layer. Models accumulate behavior over time—casts, global scopes, or observers—that can produce unintended side effects when running a migration. Using the DB facade bypasses these application-level behaviors, ensuring your migration only does exactly what you expect.

    While there are times when using a model is appropriate, defaulting to the DB facade for data migrations is a safer bet for long-term maintainability.

  • Watch GitHub Actions from your terminal with gh run watch

    Joel Clermont shared a great tip today about the GitHub CLI.

    Instead of constantly refreshing the Actions tab in your browser, you can use:

    “`bash
    gh run watch –exit-status
    “`

    This gives you a live checklist in your terminal. The `–exit-status` flag is the key—it makes the CLI exit with a non-zero code if the run fails, which is perfect for chaining commands like:

    “`bash
    gh run watch –exit-status && say “Deploying!”
    “`

  • Breaking CLI Commands into Chunked Web APIs to Avoid Timeouts

    Long-running CLI commands that work perfectly in terminal contexts often fail spectacularly in web environments. The culprit? Execution time limits and connection timeouts.

    Consider a typical bulk operation command:

    // CLI command - works but unusable in web context
    public function handle()
    {
        DB::beginTransaction();
        
        $items = Item::whereIn('id', $this->option('items'))->get();
        
        foreach ($items as $item) {
            $item->update(['status' => 'maintenance']);
            $this->processRelatedRecords($item);
        }
        
        DB::commit();
        $this->info('Done!');
    }
    

    This works great in Artisan but dies immediately when exposed via web UI – browser timeouts, server limits, no progress feedback.

    The Progressive Web API Pattern

    Break the monolithic operation into two separate endpoints:

    Step 1: Initialization Endpoint

    Validates input and creates tracking record:

    public function executeStart(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'item_ids' => 'required|array',
            'item_ids.*' => 'exists:items,id',
        ]);
        
        $items = Item::whereIn('id', $validated['item_ids'])->get();
        $snapshot = $this->buildInitialSnapshot($items);
        
        $revision = Revision::create([
            'user_id' => auth()->id(),
            'key' => 'batch_maintenance',
            'old_value' => $snapshot,
            'new_value' => $snapshot,
        ]);
        
        return response()->json([
            'status' => 'success',
            'data' => [
                'revision_id' => $revision->id,
                'items' => $items->map(fn($i) => [
                    'id' => $i->id,
                    'title' => $i->title,
                ]),
            ],
        ]);
    }
    

    Step 2: Per-Item Execution Endpoint

    Processes ONE item per request:

    public function executeItem(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'revision_id' => 'required|exists:revisions,id',
            'item_id' => 'required|exists:items,id',
        ]);
        
        $revision = Revision::findOrFail($validated['revision_id']);
        $item = Item::findOrFail($validated['item_id']);
        
        try {
            $item->update(['status' => 'maintenance']);
            $this->processRelatedRecords($item);
            
            $this->updateRevisionResult($revision, $item->id, 'success');
            
            return response()->json([
                'status' => 'success',
                'data' => ['item_id' => $item->id, 'result' => 'success'],
            ]);
        } catch (\Exception $e) {
            $this->updateRevisionResult($revision, $item->id, 'failed', $e->getMessage());
            
            return response()->json([
                'status' => 'error',
                'data' => ['item_id' => $item->id, 'result' => 'failed', 'error' => $e->getMessage()],
            ], 422);
        }
    }
    

    Why This Works

    Each request is fast. No timeout issues – every API call completes in milliseconds.

    Real-time progress. Frontend shows “Processing 5 of 20…” as each request completes.

    Partial failures don’t lose everything. If item #15 fails, items 1-14 are already committed.

    User stays in control. Can pause/resume, see exactly what succeeded vs failed.

    The Trade-Off

    You lose transactional atomicity – it’s no longer “all or nothing”. But in practice, this is acceptable for most bulk operations where seeing incremental progress and recovering from partial failures matters more than database transaction boundaries.

    For operations that truly must be atomic, keep them CLI-only. For everything else, this pattern transforms unusable monolithic commands into production-ready progressive workflows.

  • Verify Before Removing Defensive Fallback Code

    Verify Before Removing Defensive Fallback Code

    Here’s a cautionary tale about premature optimization: removing defensive fallback code before verifying the root cause is actually fixed.

    The Setup

    We had an inventory reporting command that queried product availability across date ranges. The implementation used a hybrid caching strategy:

    // Original: Hybrid caching approach
    if ($this->hasKnownCacheIssues($date)) {
        // Known edge cases: Call external API directly
        return $this->fetchFromExternalSource($date);
    }
    
    // Standard cases: Query cached database
    return $this->fetchFromCachedData($date);
    

    This hybrid approach existed because certain date ranges had known caching bugs where the cached database returned incorrect inventory counts.

    The ‘Fix’

    A colleague claimed they fixed the root cause in another PR. Based on that claim, we removed the API fallback entirely to “simplify” the code:

    // After 'fix': Removed API fallback
    // "Root cause fixed in PR #XXXX"
    return $this->fetchFromCachedData($date);  // Now ALL dates use cache
    

    Cleaner code, single responsibility, no more conditional logic. What could go wrong?

    The Reality

    Production run: command returned 0 inventory for most dates and unavailable for edge-case dates. When we queried the external API directly, it returned actual inventory numbers (237, 189, 142, etc.).

    The cache was still broken. The “root cause fix” either didn’t work or addressed a different issue entirely.

    The Lesson

    When you have defensive fallback code for known edge cases:

    1. Don’t remove it just because someone says “I fixed it” — verify the fix works for YOUR specific use case
    2. Test the edge cases explicitly — if the fallback existed for specific dates/conditions, test those exact conditions
    3. Keep the fallback until proven unnecessary — a few extra lines of “defensive” code beats broken production data
    4. Document why fallbacks exist — future-you needs to know this wasn’t just being paranoid

    Hybrid approaches often exist for good reasons. Before removing them, verify the underlying issue is ACTUALLY fixed, not just theoretically fixed.

    The Pattern

    This applies beyond caching:

    • Retry logic for flaky APIs
    • Fallback payment gateways
    • Null checks for optional relationships
    • Manual overrides for automated processes

    If defensive code exists, there’s probably a war story behind it. Find out what it is before you delete it.

  • Debugging Collection Pipelines with Tinker

    When debugging complex collection operations, it’s tempting to scatter dd() or dump() calls throughout your code. But there’s a faster way: break down the pipeline line-by-line in Tinker.

    Imagine you have a transformer method that filters and intersects date ranges:

    public function transform(DateRange $dateRange, array $validCategories)
    {
        $range = $dateRange->toCollection()
            ->filter(fn($date) => $date->isWeekday())
            ->intersect($this->getValidRange($validCategories))
            ->values();
        
        return $range->map(fn($date) => [
            'date' => $date->format('Y-m-d'),
            'available' => true,
        ]);
    }

    The method works fine in some cases but returns empty arrays in others. Instead of adding debugging statements and re-running the entire request, open Tinker and execute each transformation step:

    $dateRange = DateRange::make('2026-03-01', '2026-03-31');
    $validCategories = ['standard', 'premium'];
    
    // Step 1: Base collection
    $step1 = $dateRange->toCollection();
    dump($step1->count()); // 31 dates
    
    // Step 2: After filter
    $step2 = $step1->filter(fn($date) => $date->isWeekday());
    dump($step2->count()); // 22 weekdays
    
    // Step 3: After intersect
    $validRange = $this->getValidRange($validCategories);
    $step3 = $step2->intersect($validRange);
    dump($step3->count()); // 0 - AHA! The intersect returns nothing
    
    // Now test with different categories
    $step3 = $step2->intersect($this->getValidRange(['standard']));
    dump($step3->count()); // 10 - works with single category

    This reveals the issue: getValidRange() returns incompatible data when multiple categories are passed. The fix: ensure getValidRange() returns a flat collection of dates, not a nested structure.

    Why This Approach Works

    Breaking down collection pipelines in Tinker is faster than traditional debugging because:

    1. No need to rebuild application state — You’re working directly with live data or easily reproducible objects
    2. Instant feedback on each transformation — See exactly where the pipeline breaks
    3. Easy to test different inputs — Try various categories, date ranges, or edge cases without modifying code
    4. Clear visibility — Each step’s output is isolated and inspectable

    Bonus: Include Tinker Steps in PR Descriptions

    When writing pull request descriptions for complex bug fixes, include the Tinker reproduction steps. Reviewers can reproduce your findings without deploying code, making reviews faster and more focused:

    Bug: DateRange filtering returns empty when multiple categories are provided.

    Root cause: getValidRange() returns nested arrays instead of flat collection.

    Reproduction in Tinker:

    $step2->intersect($this->getValidRange(['standard', 'premium']))->count(); // 0
    $step2->intersect($this->getValidRange(['standard']))->count(); // 10

    This pattern works for any multi-step transformation: API response parsing, query builders with complex scopes, or deeply nested data structures. The key is isolating each step so you can see exactly where expectations diverge from reality.

  • Register Custom Corcel Models for WordPress Post Types

    By default, Corcel uses a generic Post model for all WordPress content. But if you’re working with custom post types (recipes, portfolios, reviews, etc.), you’ll want dedicated models with proper type-hinting and scopes.

    Register your custom models in config/corcel.php:

    'post_types' => [
        'recipe' => App\Models\Recipe::class,
        'review' => App\Models\Review::class,
        'portfolio' => App\Models\Portfolio::class,
    ],
    

    Then create your model:

    namespace App\Models;
    
    use Corcel\Model\Post;
    
    class Recipe extends Post
    {
        protected $postType = 'recipe';
        
        // Now you can add recipe-specific methods
        public function scopeVegetarian($query)
        {
            return $query->hasMeta('dietary_type', 'vegetarian');
        }
    }
    

    Now queries return your custom model instead of generic Posts:

    // Returns App\Models\Recipe instances
    $recipes = Recipe::vegetarian()->get();
    
    // Auto-sets post_type when creating
    $recipe = Recipe::create([
        'post_title' => 'Pasta Carbonara',
        'post_status' => 'publish',
    ]);
    

    No more checking post_type manually—your models are now type-specific.

  • Store ACF Field Keys When Using Corcel

    If you’re using Corcel to manage WordPress content from Laravel, you might notice ACF fields breaking when you create posts programmatically. The fix: ACF stores two meta entries per field—one for the value, one for the field key.

    When creating a post with ACF fields:

    $article = Article::create([
        'post_title' => 'Getting Started',
        'post_status' => 'publish',
    ]);
    
    // Save the field value
    $article->saveMeta('author_name', 'John Smith');
    
    // Also save the ACF field key reference
    $article->saveMeta('_author_name', 'field_abc123def');
    

    The _author_name meta stores the ACF field key (field_*), which ACF uses to link the value to its field configuration.

    Finding your field keys: Export your ACF field group to PHP and look for the key property on each field definition.

    Skip this and ACF’s UI won’t recognize your fields—they’ll appear as raw meta instead of proper ACF inputs.

  • Refactor Complex Inline Logic into Dedicated Classes

    When agent() calls become complex with large schemas, extract them into dedicated Agent classes. This improves readability, enables reuse, and makes testing easier.

    ❌ Before: Inline Complexity

    public function processData(string $url): ?array
    {
        $response = agent(
            instructions: 'Extract structured data from URL...',
            tools: [new WebFetch, new WebSearch],
            schema: fn (JsonSchema $schema) => [
                'name' => $schema->string()->required(),
                'items' => $schema->array()->items(
                    $schema->object([
                        'title' => $schema->string()->required(),
                        'value' => $schema->integer()->min(0)->nullable(),
                    ])
                ),
            ]
        )->prompt("Extract from: {$url}");
    }

    ✅ After: Dedicated Agent Class

    // app/Ai/Agents/DataExtractor.php
    class DataExtractor implements Agent, HasStructuredOutput
    {
        public function instructions(): string
        {
            return 'Extract structured data from URL...';
        }
    
        public function tools(): iterable
        {
            return [new WebFetch, new WebSearch];
        }
    
        public function schema(JsonSchema $schema): array
        {
            return [
                'name' => $schema->string()->required(),
                'items' => $schema->array()->items(
                    $schema->object([
                        'title' => $schema->string()->required(),
                        'value' => $schema->integer()->min(0)->nullable(),
                    ])
                ),
            ];
        }
    }
    
    // Usage:
    public function processData(string $url): ?array
    {
        return DataExtractor::prompt("Extract from: {$url}");
    }

    Why This Matters

    Laravel AI SDK’s agent() helper is great for quick prototypes, but production code benefits from structure. Dedicated Agent classes:

    • Improve readability — separate concerns into focused files
    • Enable reuse — use the same agent across multiple controllers
    • Make testing easier — mock or test agents in isolation
    • Follow Laravel conventions — single responsibility principle

    If your agent() call spans more than ~10 lines, it’s time to extract a class.

  • Using wasRecentlyCreated to Conditionally Save Metadata

    When using firstOrCreate() in seeders or migrations, check wasRecentlyCreated before saving metadata. This prevents overwriting existing relationships when re-running seeders.

    ❌ Before: Always Overwrites

    public function run(): void
    {
        foreach ($data as $item) {
            $record = Model::firstOrCreate(['name' => $item['name']]);
            $record->saveMeta('parent_id', $parentId); // Always overwrites!
        }
    }

    ✅ After: Conditional Save

    public function run(): void
    {
        foreach ($data as $item) {
            $record = Model::firstOrCreate(['name' => $item['name']]);
            if ($record->wasRecentlyCreated) {
                $record->saveMeta('parent_id', $parentId); // Only on create
            }
        }
    }

    Why This Matters

    Seeders should be idempotent — safe to run multiple times without side effects. The wasRecentlyCreated property tells you whether firstOrCreate() created a new record or found an existing one.

    This is especially important when:

    • Re-running seeders in development
    • Deploying database changes that include seed data
    • Populating initial relationships without overwriting user modifications

    The pattern ensures you populate data for new records while leaving existing ones untouched.