Category: Laravel

  • Build Flexible Plugin Systems with Interfaces and Factories





    Build Flexible Plugin Systems with Interfaces and Factories

    Build Flexible Plugin Systems with Interfaces and Factories

    When building systems that integrate with multiple external services (payment gateways, shipping providers, notification channels), hardcoding each provider creates maintenance hell. Every new integration requires touching core code.

    The solution: an interface-based plugin architecture with a Factory pattern that discovers and instantiates implementations dynamically.

    The Problem: Hardcoded Provider Lists

    // ❌ Bad: New providers require code changes
    class PaymentService
    {
        public function getProviders(): array
        {
            return [
                new StripeProvider(),
                new PayPalProvider(),
                new SquareProvider(),
            ];
        }
        
        public function charge(Order $order, string $provider)
        {
            switch ($provider) {
                case 'stripe':
                    return (new StripeProvider())->charge($order);
                case 'paypal':
                    return (new PayPalProvider())->charge($order);
                case 'square':
                    return (new SquareProvider())->charge($order);
                default:
                    throw new Exception('Unknown provider');
            }
        }
    }

    Adding a fourth provider? Modify multiple methods. Want to disable Square? Edit the switch statement. Testing? Mock every provider manually. This doesn’t scale.

    The Solution: Plugin Architecture

    Step 1: Define Your Interface

    // app/Contracts/PaymentProvider.php
    interface PaymentProvider
    {
        public function charge(Order $order): Receipt;
        public function refund(string $transactionId): bool;
        public function getName(): string;
    }

    The interface is your contract. Any class implementing it must provide these methods.

    Step 2: Implement Plugins

    // app/Providers/Payment/StripeProvider.php
    class StripeProvider implements PaymentProvider
    {
        public function charge(Order $order): Receipt
        {
            $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
            $paymentIntent = $stripe->paymentIntents->create([
                'amount' => $order->total * 100,
                'currency' => 'usd',
            ]);
            
            return new Receipt($paymentIntent->id, $order->total);
        }
        
        public function refund(string $transactionId): bool
        {
            $stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
            $stripe->refunds->create(['payment_intent' => $transactionId]);
            return true;
        }
        
        public function getName(): string
        {
            return 'Stripe';
        }
    }
    
    // Similar for PayPalProvider, SquareProvider, etc.

    Step 3: Build a Factory

    // app/Factories/PaymentFactory.php
    class PaymentFactory
    {
        public function __construct(
            private ProviderRepository $repository
        ) {}
        
        public function getProviders(): array
        {
            $providers = [];
            
            // Get active providers from database
            $configs = $this->repository->getActive();
            
            foreach ($configs as $config) {
                // Skip if class doesn't exist
                if (!class_exists($config->class)) {
                    continue;
                }
                
                // Use reflection to validate
                $reflection = new \ReflectionClass($config->class);
                
                // Skip abstracts and interfaces
                if ($reflection->isAbstract() || $reflection->isInterface()) {
                    continue;
                }
                
                // Verify implements required interface
                if (!in_array(PaymentProvider::class, class_implements($config->class))) {
                    continue;
                }
                
                // Instantiate via container (handles dependencies)
                $providers[$config->name] = app($config->class);
            }
            
            return $providers;
        }
        
        public function getProvider(string $name): ?PaymentProvider
        {
            return $this->getProviders()[$name] ?? null;
        }
        
        public function getRefundableProviders(): array
        {
            // Filter to only providers that support refunds
            return array_filter(
                $this->getProviders(),
                fn($provider) => method_exists($provider, 'refund')
            );
        }
    }

    Step 4: Database-Driven Configuration

    // database/migrations/xxxx_create_payment_providers_table.php
    Schema::create('payment_providers', function (Blueprint $table) {
        $table->id();
        $table->string('name')->unique();
        $table->string('class'); // Full class namespace
        $table->boolean('active')->default(true);
        $table->json('config')->nullable(); // Provider-specific settings
        $table->timestamps();
    });

    Seed with your providers:

    // database/seeders/PaymentProviderSeeder.php
    DB::table('payment_providers')->insert([
        ['name' => 'stripe', 'class' => StripeProvider::class, 'active' => true],
        ['name' => 'paypal', 'class' => PayPalProvider::class, 'active' => true],
        ['name' => 'square', 'class' => SquareProvider::class, 'active' => false],
    ]);

    Step 5: Use the Factory

    // In your controller or service
    class CheckoutController
    {
        public function charge(Request $request, PaymentFactory $factory)
        {
            $providerName = $request->input('payment_method');
            $provider = $factory->getProvider($providerName);
            
            if (!$provider) {
                return back()->withErrors(['payment_method' => 'Invalid payment provider']);
            }
            
            $order = Order::findOrFail($request->order_id);
            $receipt = $provider->charge($order);
            
            return view('checkout.success', compact('receipt'));
        }
    }
    
    // Batch refunds across all providers
    class RefundService
    {
        public function refundAll(array $transactionIds, PaymentFactory $factory)
        {
            foreach ($factory->getRefundableProviders() as $provider) {
                foreach ($transactionIds as $txnId) {
                    try {
                        $provider->refund($txnId);
                    } catch (\Exception $e) {
                        // Log and continue
                    }
                }
            }
        }
    }

    Advanced: Optional Interfaces

    Not all providers support all features. Use optional interfaces:

    // Optional capability interfaces
    interface SupportsRecurringPayments
    {
        public function createSubscription(Order $order, string $plan): Subscription;
    }
    
    interface SupportsSplitPayments
    {
        public function splitCharge(Order $order, array $recipients): array;
    }
    
    // Stripe implements both
    class StripeProvider implements 
        PaymentProvider, 
        SupportsRecurringPayments, 
        SupportsSplitPayments
    {
        // ... implementations
    }
    
    // PayPal only supports recurring
    class PayPalProvider implements 
        PaymentProvider, 
        SupportsRecurringPayments
    {
        // ... implementations
    }
    
    // Factory method to filter
    public function getProvidersWithRecurring(): array
    {
        return array_filter(
            $this->getProviders(),
            fn($p) => $p instanceof SupportsRecurringPayments
        );
    }

    Benefits of This Approach

    • Zero code changes to add providers – just create the class and add a database row
    • Enable/disable instantly – flip the active flag in the database
    • Easy testing – mock the interface, not individual providers
    • Type safety – interface guarantees all providers have required methods
    • Reflection validation – factory rejects invalid classes automatically
    • Dependency injection – Laravel container handles construction

    Real-World Use Cases

    System Interface Plugins
    Payment processing PaymentProvider Stripe, PayPal, Square, Authorize.net
    Shipping ShippingProvider FedEx, UPS, USPS, DHL
    Notifications NotificationChannel Email, SMS, Slack, Push, Discord
    Authentication AuthProvider OAuth (Google, GitHub), SAML, LDAP
    File storage StorageDriver S3, DigitalOcean Spaces, Local, FTP

    Key Takeaway

    When you find yourself writing switch statements or if/else chains to handle different service providers, that’s your cue to implement a plugin architecture. Define the contract (interface), let implementations handle the details, and use a factory to wire everything together.

    The upfront effort pays off immediately. Adding the fourth provider is as easy as the third. Disabling one during an outage is a database update, not a deployment. And testing becomes trivial because you mock the interface, not the implementations.

    This is how Laravel itself is built—drivers for mail, cache, queue, and filesystem all use this exact pattern. Follow the framework’s lead.


  • Idempotent Database Operations with updateOrCreate





    Idempotent Database Operations with updateOrCreate

    Idempotent Database Operations with updateOrCreate

    When syncing data from external APIs or running scheduled jobs, you need operations that are safe to run multiple times without side effects. This is called idempotency.

    Laravel’s updateOrCreate() method makes database operations naturally idempotent by either updating existing records or creating new ones based on match conditions.

    The Problem: Duplicate Records

    // ❌ Bad: Creates duplicates every time
    public function syncFromApi()
    {
        $items = Http::get('/api/products')->json();
        
        foreach ($items as $item) {
            Product::create([
                'external_id' => $item['id'],
                'name' => $item['name'],
                'price' => $item['price'],
            ]);
        }
    }

    Run this twice, and you’ll have two copies of every product. Schedule it to run hourly, and you’re accumulating garbage data.

    The Solution: updateOrCreate()

    // ✅ Good: Safe to run multiple times
    public function syncFromApi()
    {
        $items = Http::get('/api/products')->json();
        
        foreach ($items as $item) {
            Product::updateOrCreate(
                [
                    'external_id' => $item['id'], // Match on this
                ],
                [
                    'name' => $item['name'],       // Update these fields
                    'price' => $item['price'],
                ]
            );
        }
    }

    Now you can run this sync as many times as needed. Existing products update, new products create. No duplicates, no manual cleanup.

    How It Works

    1. Search – Look for a record matching the first array (where clause)
    2. Update – If found, update with the second array
    3. Create – If not found, merge both arrays and insert

    Think of the first array as your “unique identifier” and the second as your “payload to save.”

    Common Patterns

    Composite Keys

    // Match on multiple fields
    ProductVariant::updateOrCreate(
        [
            'product_id' => $productId,
            'variant_code' => $variantCode,
        ],
        [
            'name' => $name,
            'stock' => $stock,
            'price' => $price,
        ]
    );

    Webhook Processing

    // Idempotent webhook handler
    public function handle(array $webhook)
    {
        Order::updateOrCreate(
            ['external_order_id' => $webhook['order_id']],
            [
                'status' => $webhook['status'],
                'total' => $webhook['total'],
                'customer_email' => $webhook['email'],
            ]
        );
    }

    If the webhook fires twice (it happens!), you get the same result. No duplicate orders.

    Scheduled Imports

    // Hourly cron job - always safe
    $items = Cache::remember('inventory_feed', 3600, fn() => 
        Http::get($inventoryUrl)->json()
    );
    
    foreach ($items as $item) {
        InventoryItem::updateOrCreate(
            ['sku' => $item['sku']],
            [
                'quantity' => $item['qty'],
                'warehouse' => $item['location'],
                'synced_at' => now(),
            ]
        );
    }

    Performance Considerations

    updateOrCreate() runs one query to check + one query to update/insert. For bulk operations with thousands of records, consider using upsert() instead:

    // Bulk upsert (Laravel 8+)
    Product::upsert(
        [
            ['external_id' => 1, 'name' => 'Widget', 'price' => 10.00],
            ['external_id' => 2, 'name' => 'Gadget', 'price' => 20.00],
            // ... thousands more
        ],
        ['external_id'], // Unique by
        ['name', 'price'] // Update these
    );

    This runs a single bulk INSERT … ON DUPLICATE KEY UPDATE query, dramatically faster for large datasets.

    When to Use updateOrCreate()

    • API syncing – External data refreshes
    • Webhook handlers – Idempotent event processing
    • Scheduled jobs – Cron tasks that might retry
    • Import scripts – CSV/Excel uploads
    • Cache warming – Rebuild cached aggregates

    Key Takeaway

    Idempotency isn’t just a nice-to-have—it’s essential for reliable systems. Any operation that syncs external data or runs on a schedule should be idempotent by default. updateOrCreate() makes this trivial in Laravel.

    The alternative is manually checking if records exist before deciding whether to create or update. That’s error-prone, verbose, and creates race conditions. Let Eloquent handle it.


  • Use LazyCollection for Memory-Efficient API Pagination





    Use LazyCollection for Memory-Efficient API Pagination

    Use LazyCollection for Memory-Efficient API Pagination

    When working with external APIs that return large datasets, memory management becomes critical. Loading thousands of records into memory at once can quickly exhaust your PHP memory limit.

    Laravel’s LazyCollection solves this elegantly using PHP generators to yield items one at a time instead of loading everything upfront.

    The Problem: Memory Bloat

    // ❌ Bad: Loads all products into memory
    public function syncProducts(): Collection
    {
        $products = $this->apiClient->fetchAllProducts();
        return collect($products); // Could be 10,000+ items
    }

    This approach loads the entire API response into memory. With 10,000 products, you’re looking at hundreds of megabytes of RAM consumption before you’ve even started processing.

    The Solution: LazyCollection with Generators

    // ✅ Good: Yields items one at a time
    public function syncProducts(): LazyCollection
    {
        return LazyCollection::make(function () {
            yield from $this->apiClient->fetchAllProducts();
        });
    }
    
    // Process efficiently
    $this->syncProducts()->each(function ($product) {
        Product::updateOrCreate(
            ['external_id' => $product['id']],
            ['name' => $product['name'], 'price' => $product['price']]
        );
    });

    With LazyCollection, memory usage stays constant regardless of dataset size. Each item is processed and discarded before the next one is loaded.

    Real-World Impact

    Approach 10K Records 100K Records
    Collection ~512MB ~5GB (crashes)
    LazyCollection ~32MB ~32MB

    When to Use LazyCollection

    • API pagination – Syncing data from external services
    • Database exports – Generating large CSV files
    • Batch processing – Working with millions of records
    • File parsing – Reading large log files or CSV imports

    Advanced: Chunked API Calls

    If your API supports pagination, combine LazyCollection with chunking:

    public function fetchAllProducts(): LazyCollection
    {
        return LazyCollection::make(function () {
            $page = 1;
            do {
                $response = Http::get('/api/products', ['page' => $page]);
                $products = $response->json('data');
                
                foreach ($products as $product) {
                    yield $product;
                }
                
                $page++;
            } while (!empty($products));
        });
    }

    This pattern requests one page at a time, yields each item, then fetches the next page only when needed.

    Key Takeaway

    When dealing with large external datasets, LazyCollection isn’t just an optimization—it’s often the difference between a working sync script and a memory exhausted crash. Use it whenever you’re processing data where the full dataset size is unknown or potentially large.


  • Building a Flexible Plugin Architecture in Laravel Commands

    # Building a Flexible Plugin Architecture in Laravel Commands

    When building Laravel applications that need to integrate with multiple external services, a well-designed plugin architecture can save you months of refactoring. Here’s a pattern I use for artisan commands that need to work with different service providers.

    ## The Problem

    You’re building an import system that needs to sync data from multiple third-party APIs. Each provider has different endpoints, authentication, and data formats. You want to:

    – Add new providers without modifying core code
    – Share common import logic across all providers
    – Run imports for specific providers or all at once
    – Test providers independently

    ## The Solution: Interface-Based Plugin Architecture

    ### Step 1: Define the Contract

    “`php
    namespace App\Services\Integrations\Contracts;

    use App\Models\DataRecord;
    use Illuminate\Support\Collection;

    interface ImportsData
    {
    public function getMaxSyncRange(): int;

    /**
    * @return Collection
    */
    public function importData(DataRecord $record): Collection;
    }
    “`

    ### Step 2: Create an Abstract Importer

    “`php
    namespace App\Services\Integrations;

    use App\Models\DataRecord;
    use App\Services\Integrations\Contracts\ImportsData;
    use Illuminate\Support\Collection;
    use Illuminate\Support\LazyCollection;

    abstract class AbstractImporter
    {
    protected bool $verbose = false;

    public function __construct(
    protected PluginFactory $plugins,
    protected Logger $logger,
    protected Dispatcher $dispatcher
    ) {}

    abstract protected function getPlugins(): Collection;
    abstract protected function getName(): string;
    abstract protected function getJobClassName(): string;
    abstract protected function validateRecord(DataRecord $record): bool;

    public function synchronize(?string $pluginClassname = null, ?int $recordId = null, bool $sync = false): void
    {
    $this->logger->info(“Run {$this->getName()} import”);

    if ($pluginClassname && $recordId) {
    $this->synchronizeSingleRecord($pluginClassname, $recordId, $sync);
    return;
    }

    $plugins = $this->getPluginsCollection($pluginClassname);

    $plugins->each(function (string $pluginName, string $pluginClassname) use ($sync) {
    $this->logger->info(“Processing {$pluginClassname}”);

    $this->getRecords($pluginClassname)->each(function ($record) use ($sync) {
    if ($this->validateRecord($record)) {
    $this->logger->info(“Processing record: {$record->id}”);
    $this->handle($record, $sync);
    }
    });
    });
    }

    protected function handle(DataRecord $record, bool $sync): void
    {
    $job = $this->getJobClassName();

    if ($sync) {
    $job::dispatchSync($record, true);
    return;
    }

    $job::dispatch($record);
    }

    protected function getRecords(string $pluginClassname): LazyCollection
    {
    return DataRecord::with([‘related_data’])
    ->where(‘integration_plugin’, $pluginClassname)
    ->where(‘active’, true)
    ->cursor(); // Use cursor() for memory efficiency
    }
    }
    “`

    ### Step 3: Concrete Importer

    “`php
    namespace App\Services\Integrations;

    use App\Models\DataRecord;
    use App\Services\Integrations\Contracts\ImportsData;
    use App\Services\Integrations\Jobs\SynchronizeDataJob;
    use Illuminate\Support\Collection;

    class DataImporter extends AbstractImporter
    {
    protected function getName(): string
    {
    return __CLASS__;
    }

    protected function getJobClassName(): string
    {
    return SynchronizeDataJob::class;
    }

    protected function getPlugins(): Collection
    {
    return collect($this->plugins->getInterfaceImplementingPlugins(ImportsData::class));
    }

    protected function validateRecord(DataRecord $record): bool
    {
    return $record->settings
    ->where(‘sync_enabled’, true)
    ->where(‘status’, ‘active’)
    ->isNotEmpty();
    }
    }
    “`

    ### Step 4: Abstract Command Base

    “`php
    namespace App\Console\Commands\Integrations;

    use App\Services\Integrations\AbstractImporter;
    use Illuminate\Console\Command;

    class AbstractImportCommand extends Command
    {
    public function __construct(protected AbstractImporter $importer)
    {
    parent::__construct();
    }

    public function handle(): int
    {
    if ($this->option(‘verbose’)) {
    $this->importer->setVerbose();
    }

    $this->importer->synchronize(
    $this->getPluginClassName(),
    $this->option(‘recordId’),
    $this->option(‘sync’)
    );

    return 0;
    }

    public function getPluginClassName(): ?string
    {
    $pluginName = $this->option(‘plugin’);

    if (empty($pluginName)) {
    return null;
    }

    // Auto-add namespace prefix if missing
    if (!str_starts_with($pluginName, ‘App\\Services\\Integrations\\Plugins\\’)) {
    $pluginName = ‘App\\Services\\Integrations\\Plugins\\’ . $pluginName;
    }

    // Auto-add “Plugin” suffix if missing
    if (!str_ends_with($pluginName, ‘Plugin’)) {
    $pluginName = $pluginName . ‘Plugin’;
    }

    return $pluginName;
    }
    }
    “`

    ### Step 5: Concrete Command

    “`php
    namespace App\Console\Commands\Integrations;

    use App\Services\Integrations\DataImporter;

    class ImportDataCommand extends AbstractImportCommand
    {
    protected $signature = ‘integrations:import:data
    {–plugin=}
    {–recordId=}
    {–s|sync}’;

    protected $description = ‘Imports data from external APIs’;

    public function __construct(DataImporter $importer)
    {
    parent::__construct($importer);
    }
    }
    “`

    ### Step 6: Sample Plugin Implementation

    “`php
    namespace App\Services\Integrations\Plugins;

    use App\Models\DataRecord;
    use App\Services\Integrations\Contracts\ImportsData;
    use Illuminate\Support\Collection;
    use Illuminate\Support\Facades\Http;

    class StripePlugin implements ImportsData
    {
    public function getMaxSyncRange(): int
    {
    return 90; // days
    }

    public function importData(DataRecord $record): Collection
    {
    $response = Http::withToken(config(‘services.stripe.key’))
    ->get(‘https://api.stripe.com/v1/charges’, [
    ‘limit’ => 100,
    ‘customer’ => $record->external_id,
    ]);

    return collect($response->json(‘data’))
    ->pluck(‘created’)
    ->map(fn($timestamp) => date(‘Y-m-d’, $timestamp));
    }
    }
    “`

    ## Usage Examples

    “`bash
    # Import all plugins
    php artisan integrations:import:data

    # Import specific plugin (short name)
    php artisan integrations:import:data –plugin=Stripe

    # Import specific plugin (full class name)
    php artisan integrations:import:data –plugin=”App\\Services\\Integrations\\Plugins\\StripePlugin”

    # Import single record synchronously (useful for debugging)
    php artisan integrations:import:data –plugin=Stripe –recordId=123 –sync

    # Verbose output
    php artisan integrations:import:data –plugin=Stripe -v
    “`

    ## Key Benefits

    1. **Flexible Plugin Resolution**: Accepts short names (`Stripe`), partial paths, or full class names
    2. **Memory Efficient**: Uses `cursor()` instead of `get()` for large datasets
    3. **Sync/Async Toggle**: Debug synchronously, run async in production
    4. **Interface-Driven**: Plugins implement contracts, core code stays unchanged
    5. **Clean Separation**: Command → Importer → Job → Plugin (each layer has one job)

    ## Testing Tip

    Mock the plugin factory in tests to inject fake plugins:

    “`php
    public function test_import_processes_valid_records()
    {
    $mockPlugin = Mockery::mock(ImportsData::class);
    $mockPlugin->shouldReceive(‘importData’)
    ->andReturn(collect([‘2024-01-01’, ‘2024-01-02’]));

    $mockFactory = Mockery::mock(PluginFactory::class);
    $mockFactory->shouldReceive(‘getInterfaceImplementingPlugins’)
    ->andReturn(collect([‘Stripe’ => StripePlugin::class]));

    $importer = new DataImporter($mockFactory, $logger, $dispatcher);
    $importer->synchronize();

    // Assertions…
    }
    “`

    This pattern scales from 2 providers to 200 without architectural changes. Each new integration is just a new plugin class implementing the interface.

  • Method-Level Dependency Injection for Single-Use Services

    Laravel’s service container supports dependency injection at the method level, not just the constructor. When a service is only used in one controller method, inject it there instead of cluttering the constructor.

    The Problem: Constructor Bloat

    It’s common to see controllers with constructors packed with dependencies that are only used in one or two methods:

    class OrderController extends Controller
    {
        public function __construct(
            private OrderService $orderService,
            private PaymentService $paymentService,
            private ShippingService $shippingService, // Only used in ship()
        ) {}
        
        public function index() {
            return $this->orderService->list();
        }
        
        public function ship(Request $request) {
            $this->shippingService->process(...);
        }
    }

    The ShippingService is instantiated for every request to this controller, even though it’s only used in the ship() method.

    The Solution: Method-Level Injection

    Inject dependencies directly into the methods that use them:

    class OrderController extends Controller
    {
        public function __construct(
            private OrderService $orderService,
            private PaymentService $paymentService,
        ) {}
        
        public function index() {
            return $this->orderService->list();
        }
        
        public function ship(Request $request, ShippingService $shippingService) {
            $shippingService->process(...);
        }
    }

    Laravel automatically resolves method parameters after route parameters. The service container inspects the type hint and provides the appropriate instance.

    Benefits

    • Constructor stays focused on shared dependencies used across multiple methods
    • Clear intent — you can see exactly which methods use which services
    • Easier testing — mock only what that specific method needs
    • No unnecessary instantiation — dependencies are only created when the method is called

    When to Use Which

    Scenario Approach
    Service used in 3+ methods Constructor injection
    Service used in 1 method only Method injection
    Service used in 2 methods Judgment call (lean toward method)

    Route Parameters + Dependency Injection

    When combining route model binding with method injection, Laravel resolves route parameters first, then dependencies:

    // Route: /orders/{order}/ship
    public function ship(Order $order, ShippingService $shippingService) {
        // $order comes from route binding
        // $shippingService comes from container
        $shippingService->processOrder($order);
    }

    Laravel is smart enough to distinguish between route parameters (bound to the route) and type-hinted dependencies (resolved from the container).

    Takeaway

    Don’t default to constructor injection for everything. If a dependency is only used in one method, inject it there. Your constructor will stay clean, your intent will be clearer, and you’ll avoid unnecessary instantiations.

  • Impact Analysis Before Modifying Shared Methods

    When deciding whether to add new fields to a shared transformation method (like toArray() or custom serializers), always check all usages first to understand the blast radius.

    The decision:
    Should you add fields to a shared method, or add them at the call site?

    Step 1: Find all usages

    rg "MyClass::toArray\(\)" app/ --type php -B 2 -A 2
    

    Step 2: Analyze each consumer

    • Does it expect specific keys only?
    • Will extra keys cause issues?
    • Is it part of an API contract?

    Example scenario:

    // Shared method
    public static function toSettings(Product $product): array
    {
        return [
            'name' => $product->name,
            'price' => $product->price,
            // Should 'markup' go here or at call site?
        ];
    }
    
    // Consumer 1: API endpoint (wants markup)
    // Consumer 2: Alert notification (only reads price, ignores extra)
    // Consumer 3: Report transformer (spreads array into larger struct)
    

    Decision criteria:

    • If ALL consumers need it → add to shared method
    • If ONE consumer needs it → add at call site
    • If it breaks an API contract → versioned endpoint or call-site addition

    Pro tip: Use git grep or rg (ripgrep) to search, not IDE search—catches dynamic calls and string references.

  • Conditional Logic with Collection whenNotEmpty

    Laravel collections provide whenNotEmpty() and whenEmpty() methods for conditional logic execution, which is cleaner than manual empty checks.

    Before (verbose):

    if (!$order->items->isEmpty()) {
        $order->items->each(fn($item) => $item->ship());
    }
    

    After (fluent):

    $order->items->whenNotEmpty(function ($items) {
        $items->each(fn($item) => $item->ship());
    });
    

    Bonus—both branches:

    $user->notifications
        ->whenNotEmpty(fn($notifications) => $this->send($notifications))
        ->whenEmpty(fn() => Log::info('No notifications to send'));
    

    This pattern keeps your code fluent and avoids breaking the collection chain. The closure receives the collection as a parameter, so you don’t need to reference the original variable again.

  • SHOW COLUMNS — Trust Nothing, Verify Everything




    SHOW COLUMNS — Trust Nothing, Verify Everything

    Working with an unfamiliar database or inherited codebase? Don’t guess column names — inspect the schema directly with SHOW COLUMNS.

    I recently wasted 20 minutes debugging a query that referenced projects.title when the actual column was projects.name. One SQL command would have caught it:

    SHOW COLUMNS FROM projects;

    This returns the table’s structure:

    +-------------+--------------+------+-----+---------+----------------+
    | Field       | Type         | Null | Key | Default | Extra          |
    +-------------+--------------+------+-----+---------+----------------+
    | id          | int(11)      | NO   | PRI | NULL    | auto_increment |
    | name        | varchar(255) | NO   |     | NULL    |                |
    | status      | varchar(50)  | YES  |     | active  |                |
    | created_at  | timestamp    | YES  |     | NULL    |                |
    +-------------+--------------+------+-----+---------+----------------+

    Use It Programmatically

    In Laravel, you can inspect schemas at runtime:

    $columns = DB::select('SHOW COLUMNS FROM ' . $table);
    
    foreach ($columns as $col) {
        echo "{$col->Field} ({$col->Type})\n";
    }

    This is especially useful for:

    • Schema validation: Verify migrations match production
    • Legacy databases: Document undocumented systems
    • Dynamic queries: Build column lists programmatically
    • Debugging: Confirm column existence before querying

    Alternative: Laravel’s Schema Facade

    Laravel provides a cleaner API for this:

    use Illuminate\Support\Facades\Schema;
    
    // Check if column exists
    if (Schema::hasColumn('projects', 'title')) {
        // Safe to use
    }
    
    // Get all columns
    $columns = Schema::getColumnListing('projects');
    // ['id', 'name', 'status', 'created_at', 'updated_at']

    But SHOW COLUMNS gives you more metadata (type, nullable, defaults) when you need it. Way more reliable than assuming your migrations match production after years of hotfixes.


  • JSON_CONTAINS in MySQL for Schema-Free Array Searches




    JSON_CONTAINS in MySQL for Schema-Free Array Searches

    MySQL’s JSON_CONTAINS function lets you query JSON array columns without rigid schema changes. Need to filter rows based on values inside a JSON array? Use:

    WHERE JSON_CONTAINS(config_json, '"USD"')

    Notice the double quotes inside single quotes — JSON requires double-quoted strings. This searches for "USD" anywhere in the array.

    When to Use JSON Columns

    JSON columns are ideal for flexible metadata that doesn’t need structured querying. For example:

    • Feature flags per user (varies by account type)
    • Configuration options per tenant
    • Tags or labels that change frequently
    -- Find all users with "premium" feature enabled
    SELECT * FROM accounts
    WHERE JSON_CONTAINS(features, '"premium"');

    The Performance Trade-Off

    JSON searches cannot use regular indexes. For frequently-queried paths, consider generated columns with indexes:

    -- Add a generated column for faster searches
    ALTER TABLE accounts 
    ADD feature_list VARCHAR(255) 
    AS (JSON_UNQUOTE(JSON_EXTRACT(features, '$.enabled'))) STORED;
    
    -- Index it
    CREATE INDEX idx_features ON accounts(feature_list);

    This gives you JSON’s flexibility with traditional index performance. Laravel can generate these via migrations:

    Schema::table('accounts', function (Blueprint $table) {
        $table->string('feature_list')
              ->storedAs("JSON_UNQUOTE(JSON_EXTRACT(features, '$.enabled'))")
              ->index();
    });

    Use JSON for truly flexible data, but add generated columns + indexes for hot paths.


  • Debugging Data Mismatches Between UI and Backend Logic




    Debugging Data Mismatches Between UI and Backend Logic

    Ever shown users options in a dropdown that don’t actually work? I recently debugged a classic denormalization issue where the UI displayed 434 currency options based on a cached summary table, but the backend filter logic checked live data in a separate table — and only 79 of those currencies had available inventory.

    The problem occurred because two different data sources were being used:

    • Display logic: Read from a forecasts table that stored pre-computed aggregates
    • Filter logic: Queried the actual orders table for real-time availability

    The fix started with comparing aggregate counts to confirm the mismatch:

    -- Backend filter (real-time availability)
    SELECT COUNT(DISTINCT product_id) 
    FROM orders 
    WHERE status = 'available';
    -- Result: 79
    
    -- Display logic (cached aggregates)
    SELECT COUNT(*) 
    FROM forecasts 
    WHERE JSON_CONTAINS(metadata, '"currency_code"');
    -- Result: 434

    The root cause? The forecast table wasn’t being updated when inventory sold out. Users saw 434 options but could only actually use 79 of them.

    When to Cache vs. Query Live

    Caching computed data is powerful, but it introduces a new problem: cache invalidation. Before building a cache layer, ask:

    • What triggers a cache update? (e.g., order placed, inventory restocked)
    • Can users tolerate stale data? (product listings: yes; checkout: no)
    • Is the performance gain worth the complexity?

    In this case, the better approach was to either:

    1. Build the dropdown from the same real-time query the filter uses
    2. Add proper cache invalidation hooks when inventory changes

    Lesson: When you split display and business logic across different data sources, always verify your cache invalidation strategy matches your actual business rules. The mismatch becomes obvious when you compare the numbers.