Blog

  • Store Files by ID, Not by Slug

    One of the easiest ways to create accidental “data migrations” is to put user-facing strings in places you later treat as stable identifiers.

    A common example: storing uploaded files under a directory that includes a slug or title. It feels tidy… until someone renames the record and suddenly your storage path no longer matches reality.

    The rule of thumb

    Use an immutable identifier for storage paths. Keep human-readable names as metadata.

    That usually means:

    • A stable key (ULID/UUID/integer id) for file paths and URLs
    • A separate column for the original filename / display name
    • A separate column for the current “pretty” label (which can change)

    A simple Laravel approach

    Create a model that owns the upload, and give it a stable public identifier.

    // database/migrations/xxxx_xx_xx_create_documents_table.php
    Schema::create('documents', function ($table) {
        $table->id();
        $table->ulid('public_id')->unique();
    
        $table->string('display_name');      // can change
        $table->string('original_name');     // what the user uploaded
        $table->string('storage_path');      // immutable once set
    
        $table->timestamps();
    });
    

    On upload, generate the storage path from the immutable identifier (not the title):

    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\Storage;
    use Illuminate\Support\Str;
    
    public function store(Request $request)
    {
        $file = $request->file('file');
    
        $publicId = (string) Str::ulid();
        $originalName = $file->getClientOriginalName();
    
        $path = "documents/{$publicId}/" . Str::random(16) . "-" . $originalName;
    
        Storage::disk('public')->put($path, file_get_contents($file->getRealPath()));
    
        $document = Document::create([
            'public_id' => $publicId,
            'display_name' => pathinfo($originalName, PATHINFO_FILENAME),
            'original_name' => $originalName,
            'storage_path' => $path,
        ]);
    
        return response()->json([
            'id' => $document->public_id,
        ]);
    }
    

    Why this pays off

    • Renames are trivial: update display_name, nothing else.
    • Downloads don’t break when labels change.
    • You can safely rebuild “pretty URLs” later without moving files.

    If you want tidy URLs, you can still add a slug — just don’t make your storage system depend on it.

  • Layered Permission Checks with Context-Aware Authorization




    Layered Permission Checks with Context-Aware Authorization

    Layered Permission Checks with Context-Aware Authorization

    Role-based permissions are great for simple features. But what if you need different access rules based on the state of the data being accessed?

    Don’t just check roles. Layer your permission checks with context-aware logic.

    The Problem

    You have an “Advanced Settings” section. Originally, only super admins could see it:

    if (auth()->user()->hasRole('super.admin')) {
        // Show advanced settings
    }

    But now you need more complex rules:

    • Tech team should always see it (for debugging)
    • Regular admins can see it for draft records
    • Only executives can see it for published records

    Trying to cram all this into role checks gets messy fast.

    The Solution: Layered Authorization

    Break authorization into layers:

    1. Base permission: Does the user have the feature enabled at all?
    2. Context check: What state is the record in?
    3. Role check: Does their role allow access for this record state?
    4. Escape hatch: Always allow admin users (for debugging)

    In code:

    function canViewAdvancedSettings(User $user, Report $report): bool
    {
        // Layer 1: Tech team bypass (debugging)
        if ($user->hasRole('tech.team')) {
            return true;
        }
        
        // Layer 2: Base permission check
        if (!$user->hasPermission('view.advanced.settings')) {
            return false;
        }
        
        // Layer 3: Context-aware role check
        if ($report->isPublished()) {
            // Published reports: only executives
            return $user->hasRole('executive');
        }
        
        // Default: any user with base permission can view drafts
        return true;
    }

    Why This Works

    • Tech team always gets in: They need to debug production issues
    • Base permission as gate: Users without the permission never see the feature
    • Context-aware rules: Different record states have different access requirements
    • Explicit defaults: Clear fallback behavior when special cases don’t apply

    Blade Integration

    Use this in your blade templates:

    @php
        $showAdvanced = (function($user, $report) {
            if ($user->hasRole('tech.team')) {
                return true;
            }
            
            if (!$user->hasPermission('view.advanced.settings')) {
                return false;
            }
            
            if ($report->isPublished()) {
                return $user->hasRole('executive');
            }
            
            return true;
        })(auth()->user(), $report);
    @endphp
    
    @if($showAdvanced)
        <div class="advanced-settings">
            {{-- Advanced settings UI --}}
        </div>
    @endif

    Alternative: Laravel Policies

    For reusable logic, extract this to a policy method:

    // app/Policies/ReportPolicy.php
    public function viewAdvancedSettings(User $user, Report $report): bool
    {
        if ($user->hasRole('tech.team')) {
            return true;
        }
        
        if (!$user->hasPermission('view.advanced.settings')) {
            return false;
        }
        
        if ($report->isPublished()) {
            return $user->hasRole('executive');
        }
        
        return true;
    }

    Then use @can in your blade templates:

    @can('viewAdvancedSettings', $report)
        <div class="advanced-settings">
            {{-- Advanced settings UI --}}
        </div>
    @endcan

    Key Insight

    Don’t try to solve complex authorization with just roles. Layer your checks:

    1. Start with escape hatches (tech/admin bypass)
    2. Check base permissions
    3. Apply context-aware rules (record state, user attributes, etc.)
    4. Provide clear defaults

    This pattern scales much better than trying to create a role for every combination of access rules.

    Category: Laravel | Keywords: Laravel, permissions, authorization, role-based, context-aware, security


  • Reusing Single-Record Components for Bulk Operations




    Reusing Single-Record Components for Bulk Operations

    Reusing Single-Record Components for Bulk Operations

    You have a TaskEditor component that works perfectly for editing one task. Now you need bulk editing. Should you build a separate BulkTaskEditor component from scratch?

    No. Make your single-record component bulk-capable by designing for optional prepopulation.

    The Pattern

    Your single-record editor passes all the defaults explicitly:

    <!-- SingleTaskEdit.vue -->
    <StatusUpdateForm
        :defaultStatus="task.status"
        :defaultDueDate="task.due_date"
        :defaultAssignee="task.assignee_id"
        :tasks="[task]"
    />

    For bulk editing, just pass the common values (or nothing if they differ):

    <!-- BulkTaskEdit.vue -->
    <StatusUpdateForm
        :defaultStatus="commonStatus"
        :defaultDueDate="commonDueDate"
        :defaultAssignee="commonAssignee"
        :tasks="selectedTasks"
    />

    The StatusUpdateForm component doesn’t know (or care) if it’s handling one task or fifty. It just uses whatever defaults you pass.

    How to Make This Work

    Design your child component with optional props that have sensible defaults:

    // StatusUpdateForm.vue
    export default {
        props: {
            tasks: {
                type: Array,
                required: true
            },
            defaultStatus: {
                type: String,
                default: null  // Null = user must choose
            },
            defaultDueDate: {
                type: String,
                default: null
            },
            defaultAssignee: {
                type: Number,
                default: null
            }
        }
    }

    When a default is null, the form field starts empty. When it has a value, the field is prepopulated.

    The Parent’s Job

    The parent component (single or bulk) decides what to pass:

    // BulkTaskEdit.vue
    computed: {
        commonStatus() {
            const statuses = this.selectedTasks.map(t => t.status);
            const unique = [...new Set(statuses)];
            return unique.length === 1 ? unique[0] : null;
        }
    }

    If all selected tasks have status: "pending", pass "pending". Otherwise, pass null.

    Why This Beats Separate Components

    • One source of truth: Bug fixes apply to both single and bulk editing
    • Consistent validation: Same rules, same error messages
    • Less code: No duplicated form logic
    • Easier testing: Test the component once with different prop combinations

    Key Insight

    The child component should be data-agnostic. It doesn’t care where the defaults come from or how many records it’s editing. It just needs:

    1. Optional default values (or null)
    2. An array of records to update

    The parent decides what defaults to pass based on whether it’s single or bulk editing.

    Rule of thumb: If your component takes a single record as input, refactor it to accept an array of records plus optional defaults. You’ve just made it bulk-capable with zero extra UI code.

    Category: Laravel | Keywords: Vue.js, component reuse, optional props, DRY, Laravel, architecture


  • Prepopulating Bulk Edit Forms with Common Values




    Prepopulating Bulk Edit Forms with Common Values

    Prepopulating Bulk Edit Forms with Common Values

    You’ve built a single-record edit form that works great. Now users want to edit multiple records at once. Should the form start empty, or show the values from the selected records?

    The answer: only prepopulate when all selected records share the same value.

    The Problem

    When editing a single task, your form looks like this:

    <UpdateStatusForm
        :defaultStartDate="task.start_date"
        :defaultEndDate="task.end_date"
        :defaultPriority="task.priority"
        :tasks="[task]"
    />

    But for bulk editing, if you pass no defaults:

    <UpdateStatusForm
        :tasks="selectedTasks"
    />

    …the form starts completely empty, even if all 50 selected tasks have the same due date. Users have to re-enter values that are already consistent across the selection.

    The Solution: Computed Common Values

    Add computed properties to check if all selected records share the same value:

    computed: {
        commonStartDate() {
            const dates = this.selectedTasks.map(t => t.start_date);
            const unique = [...new Set(dates)];
            return unique.length === 1 ? unique[0] : null;
        },
        
        commonEndDate() {
            const dates = this.selectedTasks.map(t => t.end_date);
            const unique = [...new Set(dates)];
            return unique.length === 1 ? unique[0] : null;
        },
        
        commonPriority() {
            const priorities = this.selectedTasks.map(t => t.priority);
            const unique = [...new Set(priorities)];
            return unique.length === 1 ? unique[0] : null;
        }
    }

    Then pass these to your form component:

    <UpdateStatusForm
        :defaultStartDate="commonStartDate"
        :defaultEndDate="commonEndDate"
        :defaultPriority="commonPriority"
        :tasks="selectedTasks"
    />

    What This Does

    • All 50 tasks have the same due date? Form prepopulates with that date.
    • 25 tasks are “high” priority, 25 are “low”? Priority field stays empty (no common value).
    • 48 tasks have no start date (null)? Start date field stays empty (null is the common value).

    Key Insight

    This pattern works because your child component already supports optional defaults with sensible fallbacks:

    props: {
        defaultStartDate: { type: String, default: null },
        defaultEndDate: { type: String, default: null },
        defaultPriority: { type: String, default: '' }
    }

    The child component doesn’t care if it’s editing one record or fifty. It just uses whatever defaults you pass.

    Rule of thumb: Only prepopulate when every record in the selection has the same value. This prevents confusion and shows users which fields are consistent across their selection.

    Category: Laravel | Keywords: bulk edit, form prepopulation, Vue.js, computed properties, Laravel, UX


  • Breaking Down Complex Boolean Logic for Readability

    When you have complex boolean conditions, don’t cram them into one giant expression. Break them into named variables that explain what you’re checking, not just how.

    Before (hard to read)

    if ($record->getConfig()?->getKey() === null && is_string($record->api_plugin) && app(ConfigRepository::class)->configsFor($record->api_plugin)->containsOneItem() && method_exists($record->api_plugin, 'getClients') && app($record->api_plugin)->getClients()->contains(fn (Client $client) => !$client->getConfig() instanceof DynamicConfig) === false) {
        $record->setConfig(app(ConfigRepository::class)->configsFor($record->api_plugin)->first());
    }

    This works, but it’s a wall of logic. What are we actually checking?

    After (self-documenting)

    if ($record->getConfig()?->getKey() === null && is_string($record->api_plugin)) {
        $configs = app(ConfigRepository::class)->configsFor($record->api_plugin);
        
        $hasOnlyOneConfig = $configs->containsOneItem();
        $pluginSupportsClients = method_exists($record->api_plugin, 'getClients');
        
        $allClientsUseDynamicConfigs = $pluginSupportsClients
            && app($record->api_plugin)->getClients()
                ->contains(fn (Client $client) => !$client->getConfig() instanceof DynamicConfig) === false;
        
        if ($hasOnlyOneConfig && $pluginSupportsClients && $allClientsUseDynamicConfigs) {
            $record->setConfig($configs->first());
        }
    }

    Now it’s obvious:

    • We’re checking if there’s only one config
    • We’re checking if the plugin supports clients
    • We’re checking if all clients use dynamic configs

    The variable names document the intent, not just the implementation. Future-you (or your teammates) will thank you.

    Bonus: This makes debugging easier. You can dump individual conditions to see which one is failing.

  • Auto-Fixing UI State Mismatches in Laravel Backend

    Sometimes UI state and database state get out of sync – especially with single-option dropdowns in Vue.js where the UI shows a selected value but v-model is actually null. Instead of forcing users to manually fix this, detect and auto-correct it in the backend.

    The Scenario

    A form dropdown shows one option (e.g., “Production API”), it appears selected visually, but because there’s no “–Select One–” empty option, the v-model is still null. The form submits, and the backend receives null for a required field.

    Backend Auto-Fix

    public function forRecord(Record $record)
    {
        // Auto-assign when UI shows single option but v-model is null
        if ($record->getConfig()?->getKey() === null && is_string($record->api_plugin)) {
            $configs = app(ConfigRepository::class)->configsFor($record->api_plugin);
            
            $hasOnlyOneConfig = $configs->containsOneItem();
            $pluginSupportsClients = method_exists($record->api_plugin, 'getClients');
            
            $allClientsUseDynamicConfigs = $pluginSupportsClients
                && app($record->api_plugin)->getClients()
                    ->contains(fn (Client $client) => !$client->getConfig() instanceof DynamicConfig) === false;
            
            if ($hasOnlyOneConfig && $pluginSupportsClients && $allClientsUseDynamicConfigs) {
                $record->setConfig($configs->first());
            }
        }
        
        $dynamic = $record->configs->unique()?->first();
        return $dynamic ? new DynamicConfig($dynamic) : $this->getDefault();
    }

    When to Use This Pattern

    • Only one logical choice exists
    • UI appears to show it as selected
    • Missing value causes errors downstream
    • You want graceful degradation instead of hard failures

    When NOT to Use

    • Multiple valid options exist
    • Selection is genuinely optional
    • You want strict validation

    This pattern mimics the UI’s apparent behavior in the backend, making the system more forgiving of common UI/UX patterns.

  • Handling Laravel Collection first() with Strict Return Types

    When using Laravel collections with strict return types, be careful with first() – it returns null when the collection is empty, which can cause TypeErrors with strict return type declarations.

    The Problem

    public function getDefault(): ConfigInterface
    {
        return $this->filter(fn ($config) => $config instanceof DefaultConfig)->first();
    }

    If the filtered collection is empty, first() returns null, but the method signature promises ConfigInterface. PHP 8+ strict types will throw a TypeError.

    Solution Options

    1. Nullable return type (safest)

    public function getDefault(): ?ConfigInterface
    {
        return $this->filter(fn ($config) => $config instanceof DefaultConfig)->first();
    }

    2. Throw exception (fail-fast)

    public function getDefault(): ConfigInterface
    {
        $config = $this->filter(fn ($config) => $config instanceof DefaultConfig)->first();
        
        if ($config === null) {
            throw new \RuntimeException('No default config found');
        }
        
        return $config;
    }

    3. Provide default value

    public function getDefault(): ConfigInterface
    {
        return $this->filter(fn ($config) => $config instanceof DefaultConfig)
                    ->first() ?? new NullConfig();
    }

    Choose based on your use case:

    • Nullable for optional values
    • Exception for required values
    • Default object for null object pattern
  • Multi-Method Extension Verification for Debugging

    Multi-Method Extension Verification for Debugging

    When debugging missing PHP extension errors in Docker, use multiple verification methods to confirm what’s actually installed vs what the application can see.

    Method 1: List All Loaded Modules

    docker exec my-app php -m
    

    Method 2: Runtime Check via PHP Code

    docker exec my-app php -r "echo extension_loaded('redis') ? 'YES' : 'NO';"
    

    Method 3: Check php.ini Configuration

    docker exec my-app php --ini
    # Shows which ini files are loaded
    

    Method 4: Test Actual Function Availability

    docker exec my-app php -r "var_dump(function_exists('redis_connect'));"
    

    Why Multiple Methods?

    • php -m shows modules PHP knows about
    • extension_loaded() checks if the extension is active in the current context
    • Function checks verify the extension’s API is actually usable

    In debugging sessions, cross-reference all three to isolate whether the issue is installation, configuration, or application-level detection.

    Pro tip: If php -m shows the extension but extension_loaded() returns false, check your php.ini configuration and verify the extension is enabled for the SAPI you’re using (CLI vs FPM).

  • Understanding PHP Database Extension Requirements

    Understanding PHP Database Extension Requirements

    Different PHP frameworks and CMSs have specific database extension requirements. Installing pdo_mysql doesn’t automatically give you access to the legacy mysql or mysqli extensions — they’re separate modules.

    Check What’s Installed

    php -m | grep -i mysql
    # Output might show:
    pdo_mysql
    mysqlnd
    

    Verify Specific Extension Availability

    <?php
    if (extension_loaded('pdo')) {
        echo "PDO: Available\n";
    }
    
    if (extension_loaded('mysqli')) {
        echo "MySQLi: Available\n";
    } else {
        echo "MySQLi: MISSING\n";
    }
    

    The Legacy Trap

    Many legacy applications explicitly check for mysqli or mysql functions, even if PDO is available. When migrating to Docker or upgrading PHP versions, audit your Dockerfile’s docker-php-ext-install list against your framework’s actual requirements, not assumptions.

    Example: Some CMS platforms check function_exists('mysqli_connect') during installation, which fails if only pdo_mysql is present.

    The Fix

    # Add to your Dockerfile
    RUN docker-php-ext-install \
        pdo_mysql \
        mysqli \
        # ...other extensions
    

    Don’t assume one database extension covers all use cases. Check the framework’s documentation and test explicitly.

  • Docker PHP Extensions: Build-Time vs Runtime Installation

    Docker PHP Extensions: Build-Time vs Runtime Installation

    When working with PHP in Docker, you have two choices for installing extensions: build them into your Dockerfile (permanent) or install them at runtime via setup scripts (temporary). Build-time installation is the recommended approach.

    Build-Time (Dockerfile)

    FROM php:8.2-fpm
    
    RUN docker-php-ext-install \
        pdo_pgsql \
        redis \
        gd \
        opcache
    

    Runtime (setup script)

    #!/bin/bash
    docker exec my-app docker-php-ext-install redis
    docker restart my-app
    

    The Problem with Runtime Installation

    You must run your setup script after every docker-compose up. If you forget, your application breaks with “extension not found” errors.

    Build-time installation ensures extensions are always present when the container starts. No manual intervention required.

    Real-World Impact

    If your CMS shows “missing database extension” errors after container restarts, check whether the extension is in your Dockerfile’s RUN statement or only installed via post-startup scripts.

    Rule of thumb: If it needs to exist every time the container runs, it belongs in your Dockerfile.