Category: Laravel

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

  • Debug Laravel Cache Issues with Conditional Cache Parameters

    When you’re debugging data inconsistencies in Laravel and suspect caching might be the culprit, here’s a pattern that can save hours of investigation.

    The Problem

    You have a calculator class that computes prices. Sometimes it returns stale data from Redis, sometimes fresh data from the database. The bug is intermittent and hard to reproduce. How do you prove cache is the issue?

    The Debug Pattern

    Add a cache parameter to your method and compare both results side-by-side:

    // Test with cache enabled (default behavior)
    $cachedPrice = $calculator->calculate($orderId);
    
    // Test with cache explicitly disabled  
    $freshPrice = $calculator->calculate($orderId, use_cache: false);
    
    if ($cachedPrice !== $freshPrice) {
        // Cache poisoning confirmed
    }
    

    This immediately tells you if cache is the problem without clearing cache globally or adding debug logging everywhere.

    The Implementation

    Add a boolean parameter to methods that might cache data:

    class PriceCalculator
    {
        public function calculate(
            int $orderId,
            bool $use_cache = true
        ): float {
            if (!$use_cache) {
                return $this->calculateFromDatabase($orderId);
            }
    
            return Cache::remember(
                "order_price_{$orderId}",
                now()->addHours(1),
                fn() => $this->calculateFromDatabase($orderId)
            );
        }
    
        private function calculateFromDatabase(int $orderId): float
        {
            // Fresh calculation from DB
            return Order::find($orderId)->items->sum('total');
        }
    }
    

    Use Named Parameters

    PHP 8+ named parameters make cache-disabling calls self-documenting:

    // Clear intent - this call bypasses cache
    $price = $calculator->calculate($orderId, use_cache: false);
    
    // vs the old way (less obvious)
    $price = $calculator->calculate($orderId, false);
    

    When to Disable Cache

    Keep cache enabled by default (performance), but disable it for operations where stale data causes bugs:

    class OrderRepository
    {
        public function create(array $data): Order
        {
            // Order creation must use fresh prices
            $price = $this->calculator->calculate(
                $data['product_id'],
                use_cache: false
            );
    
            return Order::create([
                'product_id' => $data['product_id'],
                'price' => $price,
            ]);
        }
    }
    

    Takeaway

    When you suspect cache issues, don’t guess. Add a cache parameter, compare results, and you’ll know instantly if cache is the problem. Then you can fix it surgically without breaking performance elsewhere.

  • Building API Transformers with Multiple Interfaces in Laravel

    Need to transform data between two different API formats? Implement both interfaces in a single service class. This pattern creates a bridge that speaks both languages.

    The Problem

    You’re building a mobile app that expects data in Format A (clean REST), but your data source provides Format B (legacy XML-RPC or a different REST structure). You need to translate between them without rewriting either side.

    The Solution: Dual Interface Implementation

    Create a transformer service that implements both interfaces:

    <?php
    
    namespace App\Services;
    
    use App\Contracts\SourceApiInterface;  // What you're fetching from
    use App\Contracts\TargetApiInterface;  // What clients expect
    
    class DataTransformer implements SourceApiInterface, TargetApiInterface
    {
        private HttpClient $client;
        
        public function __construct(HttpClient $client)
        {
            $this->client = $client;
        }
    
        // ===================================================
        // SOURCE API METHODS (fetch from external API)
        // ===================================================
        
        public function fetchProducts(array $filters): array
        {
            // Fetch from external API
            $response = $this->client->get('/v1/items', $filters);
            return $response->json();
        }
        
        public function fetchCategories(): array
        {
            $response = $this->client->get('/v1/taxonomies');
            return $response->json();
        }
    
        // ===================================================
        // TARGET API METHODS (transform + expose to clients)
        // ===================================================
        
        public function getItems(string $category, int $limit = 20): array
        {
            // Map category to source filter
            $sourceFilters = ['taxonomy_id' => $this->mapCategory($category)];
            
            // Fetch using source API method
            $sourceData = $this->fetchProducts($sourceFilters);
            
            // Transform to target format
            return $this->transformToItems($sourceData);
        }
        
        public function getItemDetails(string $id): array
        {
            $sourceData = $this->fetchProduct($id);
            return $this->transformToItemDetails($sourceData);
        }
    
        // ===================================================
        // PRIVATE TRANSFORMATION LOGIC
        // ===================================================
        
        private function transformToItems(array $sourceProducts): array
        {
            return array_map(function ($product) {
                return [
                    'id' => $product['item_id'],
                    'title' => $product['name'],
                    'price' => $product['cost'] / 100,  // cents to dollars
                    'stock' => $product['inventory']['available'],
                ];
            }, $sourceProducts);
        }
        
        private function mapCategory(string $targetCategory): int
        {
            // Map client-facing category names to source taxonomy IDs
            return match($targetCategory) {
                'electronics' => 15,
                'books' => 8,
                'clothing' => 23,
                default => 1,
            };
        }
    }
    

    How It Works

    1. Source interface methods (fetchProducts, fetchCategories) handle raw API calls to the external service
    2. Target interface methods (getItems, getItemDetails) expose the clean API your clients expect
    3. Transformation happens in private methods that map fields, rename keys, convert units, etc.

    The transformer becomes a bidirectional adapter — it speaks the source API internally and the target API publicly.

    When to Use This Pattern

    • Wrapping legacy APIs with modern REST interfaces
    • Multi-source data aggregation where you combine APIs into one unified interface
    • API versioning — v2 interface transforms data from v1 endpoints
    • SDK replacement — your app expects Reddit API format, but you’re pulling from WordPress

    Benefits

    Type safety: Both interfaces enforce contracts at compile time

    Testability: Mock either interface independently

    Single responsibility: One class, one job — transform between two formats

    Swappability: Replace the source API without changing client code (dependency inversion)

    Real-World Example

    A mobile app needed a Reddit-style API (posts, comments, upvotes) but the content lived in WordPress. The transformer implemented both WordPressApiInterface and RedditApiInterface:

    • fetchPosts() called WordPress REST API
    • getHot() transformed WP posts into Reddit listing format
    • Mobile app saw clean Reddit JSON, never touched WordPress directly

    Key insight: When formats overlap, the target format wins. The transformer hides complexity from clients.