Category: Laravel

  • 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.

  • 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


  • 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


  • 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
  • Queue Large Reports, Don’t Block HTTP Requests

    The Problem

    Your users request a large CSV export of 50,000 orders. You build the file synchronously in a controller:

    public function export()
    {
        $file = fopen('php://temp', 'w+');
        
        Order::chunk(1000, function ($orders) use ($file) {
            foreach ($orders as $order) {
                fputcsv($file, [$order->id, $order->total, $order->status]);
            }
        });
        
        rewind($file);
        return response()->stream(function() use ($file) {
            fpassthru($file);
        }, 200, [
            'Content-Type' => 'text/csv',
            'Content-Disposition' => 'attachment; filename="orders.csv"',
        ]);
    }

    The request times out after 30 seconds. The user sees a 504 Gateway Timeout error.

    The Fix

    Dispatch a queued job that emails the file when it’s ready:

    // Controller
    public function export(Request $request)
    {
        GenerateOrdersReport::dispatch($request->user());
        
        return back()->with('success', 'Report is being generated. We\'ll email you when it\'s ready.');
    }
    
    // app/Jobs/GenerateOrdersReport.php
    class GenerateOrdersReport implements ShouldQueue
    {
        public $timeout = 600; // 10 minutes
        public $tries = 1;
        
        public function __construct(
            private User $user
        ) {}
        
        public function handle()
        {
            ini_set('memory_limit', '512M');
            
            $filename = 'orders-' . now()->format('Y-m-d-His') . '.csv';
            $path = storage_path('app/exports/' . $filename);
            
            $file = fopen($path, 'w');
            fputcsv($file, ['Order ID', 'Total', 'Status']); // header
            
            Order::chunk(1000, function ($orders) use ($file) {
                foreach ($orders as $order) {
                    fputcsv($file, [$order->id, $order->total, $order->status]);
                }
            });
            
            fclose($file);
            
            $this->user->notify(new ReportReady($filename));
        }
    }

    The notification email includes a download link:

    // app/Notifications/ReportReady.php
    class ReportReady extends Notification
    {
        public function __construct(
            private string $filename
        ) {}
        
        public function via($notifiable)
        {
            return ['mail'];
        }
        
        public function toMail($notifiable)
        {
            $url = route('reports.download', $this->filename);
            
            return (new MailMessage)
                ->subject('Your report is ready')
                ->line('Your export has been generated.')
                ->action('Download Report', $url)
                ->line('This link expires in 24 hours.');
        }
    }

    Why It Works

    • No timeout — The job can run for minutes without hitting HTTP limits
    • User feedback — Instant response (“We’re working on it”) instead of a hanging request
    • Memory control — Explicit ini_set('memory_limit') prevents runaway processes
    • Retry safety$tries = 1 prevents duplicate reports if the job fails midway

    Best Practices

    1. Set explicit timeouts — Don’t let jobs run forever
    2. Clean up old files — Schedule a daily job to delete exports older than 7 days
    3. Rate limit export requests — Prevent users from queuing 10 exports at once
    4. Track job status — Store $job->getJobId() in the database so you can show “In Progress” UI

    For any operation that takes more than 5 seconds, move it to a queue. Users appreciate instant feedback more than waiting for a loading spinner.

  • View Composers Keep Controllers Thin

    The Problem

    Your admin panel has a product filter form. Every controller method that renders this view needs to pass the same dropdown data:

    // ProductController@index
    public function index()
    {
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.index', compact('categories', 'brands', 'statuses'));
    }
    
    // ProductController@create
    public function create()
    {
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.create', compact('categories', 'brands', 'statuses'));
    }
    
    // ProductController@edit
    public function edit($id)
    {
        $product = Product::findOrFail($id);
        $categories = Category::pluck('name', 'id');
        $brands = Brand::pluck('name', 'id');
        $statuses = ['active', 'draft', 'archived'];
        
        return view('products.edit', compact('product', 'categories', 'brands', 'statuses'));
    }

    Duplicated logic across multiple methods. When you add a new filter dropdown, you update 5+ methods.

    The Fix

    Use a view composer to bind data automatically:

    // app/Providers/ViewServiceProvider.php
    use Illuminate\Support\Facades\View;
    use App\Models\Category;
    use App\Models\Brand;
    
    public function boot()
    {
        View::composer('products.*', function ($view) {
            $view->with([
                'categories' => Category::pluck('name', 'id'),
                'brands' => Brand::pluck('name', 'id'),
                'statuses' => ['active', 'draft', 'archived'],
            ]);
        });
    }

    Now your controllers stay thin:

    public function index()
    {
        return view('products.index');
    }
    
    public function create()
    {
        return view('products.create');
    }
    
    public function edit($id)
    {
        $product = Product::findOrFail($id);
        return view('products.edit', compact('product'));
    }

    How It Works

    The composer runs before every view that matches products.*. The data is automatically injected, so your Blade templates can access $categories, $brands, and $statuses without the controller passing them.

    When to Use It

    • Shared dropdown data across multiple views (categories, statuses, countries)
    • Default filter values that every page needs
    • Navigation menus or sidebars that appear on many pages
    • Current user permissions for conditional UI elements

    Trade-Offs

    The data is fetched on every view render, even if the template doesn’t use it. If your composer queries are expensive, cache them or use class-based composers with dependency injection for better control.

    But for simple dropdown data, view composers eliminate duplication and keep your controllers focused on actions, not view preparation.

  • Skip Eloquent for Large Dataset Exports

    The Problem

    You’re building a CSV export feature for your admin panel. Users can download product catalogs with thousands of rows. You write this:

    $products = Product::with(['category', 'brand', 'variants'])
        ->get();
    
    foreach ($products as $product) {
        $row = [
            $product->name,
            $product->category->name,
            $product->brand->name,
            $product->variants->count(),
        ];
        fputcsv($file, $row);
    }

    The job runs for 2 minutes, then crashes with “Allowed memory size exhausted.” You’re loading 10,000 Eloquent models with all their relationships into memory.

    The Fix

    Use the query builder directly. Fetch only the columns you need:

    DB::table('products')
        ->leftJoin('categories', 'products.category_id', '=', 'categories.id')
        ->leftJoin('brands', 'products.brand_id', '=', 'brands.id')
        ->select([
            'products.name as product_name',
            'categories.name as category_name',
            'brands.name as brand_name',
            DB::raw('(SELECT COUNT(*) FROM variants WHERE variants.product_id = products.id) as variant_count')
        ])
        ->chunk(1000, function ($rows) use ($file) {
            foreach ($rows as $row) {
                fputcsv($file, [
                    $row->product_name,
                    $row->category_name,
                    $row->brand_name,
                    $row->variant_count,
                ]);
            }
        });

    Why It Works

    • Chunking — Processes 1,000 rows at a time instead of loading all 10,000
    • Raw arrays — No Eloquent model overhead (attributes, casts, accessors, relationships)
    • Selective columns — Fetches only what the CSV needs, not every database column
    • Manual joins — One query instead of N+1 queries for each relationship

    Trade-Offs

    You lose Eloquent’s conveniences (accessors, casts, automatic timestamps). But for batch exports and reports, raw performance matters more than elegant code.

    Save Eloquent for interactive UIs where you’re displaying 10-50 records at a time. For bulk operations that touch thousands of rows, drop down to the query builder.