Author: Daryle De Silva

  • Backend Sorting with GET Parameters Instead of JavaScript

    When building sortable lists, the temptation is to handle sorting in JavaScript—intercept clicks, reorder the DOM, maybe use a library like DataTables. But there’s a simpler, more robust approach: let the backend handle it via GET parameters.

    The Backend Sorting Pattern

    Instead of JavaScript, pass sorting preferences through the URL:

    $allowedSorts = ['created_at', 'updated_at', 'name', 'price'];
    $sort = request('sort', 'created_at');
    $direction = request('direction', 'desc');
    
    if (!in_array($sort, $allowedSorts)) {
        $sort = 'created_at';
    }
    
    $items = Item::orderBy($sort, $direction)->paginate(20);

    Then in your view, generate sort links:

    <select onchange="window.location.href=this.value">
        <option value="?sort=created_at&direction=desc" {{ request('sort') == 'created_at' ? 'selected' : '' }}>
            Newest First
        </option>
        <option value="?sort=name&direction=asc" {{ request('sort') == 'name' ? 'selected' : '' }}>
            Name (A-Z)
        </option>
        <option value="?sort=price&direction=asc" {{ request('sort') == 'price' ? 'selected' : '' }}>
            Price (Low to High)
        </option>
    </select>

    Why Backend Sorting Wins

    State in the URL: Users can bookmark a sorted view, share links, and browser back/forward works correctly. With JS sorting, the URL doesn’t change—the state lives only in memory.

    Works with pagination: Client-side sorting breaks when you paginate (you’re only sorting the current page). Backend sorting applies across all records.

    No Vue template issues: If you’re using Vue, putting <script> tags in templates causes parsing errors. Backend sorting keeps JavaScript out of your Blade/Vue templates entirely.

    RESTful and simple: The URL describes the resource state. It’s the way the web was designed to work.

    Whitelist Validation is Critical

    Notice the $allowedSorts array? This prevents SQL injection via column names:

    if (!in_array($sort, $allowedSorts)) {
        $sort = 'created_at'; // fallback to default
    }

    Without this check, a malicious user could inject arbitrary SQL by crafting a URL like ?sort=malicious_column. Always validate sort columns against a whitelist.

    Handling Relationships

    You can even sort by related model columns using joins:

    $allowedSorts = ['created_at', 'name', 'category_name'];
    $sort = request('sort', 'created_at');
    
    if ($sort === 'category_name') {
        $items = Item::join('categories', 'items.category_id', '=', 'categories.id')
            ->select('items.*')
            ->orderBy('categories.name', $direction)
            ->paginate(20);
    } else {
        $items = Item::orderBy($sort, $direction)->paginate(20);
    }

    Preserving Other Query Parameters

    If your page has filters or search, append them to sort links using request()->except():

    $queryParams = request()->except('sort', 'direction');
    $queryParams['sort'] = 'name';
    $queryParams['direction'] = 'asc';
    
    <a href="?{{ http_build_query($queryParams) }}">Sort by Name</a>

    Or use Laravel’s appends method on paginated results:

    {{ $items->appends(request()->except('page'))->links() }}

    When JavaScript Sorting Makes Sense

    There are still valid use cases for client-side sorting:

    • Small datasets (< 100 rows) that fit on one page
    • Real-time data that updates frequently via WebSocket
    • Interactive tables where instant response is critical (trading dashboards, etc.)

    But for most admin panels and user-facing lists, backend sorting with GET parameters is simpler, more robust, and plays nicely with pagination, bookmarking, and server-side rendering.

    Let the URL do the work. It’s already there for a reason.

  • Eloquent Aggregate Filtering with selectRaw and havingRaw

    When you need to filter records based on aggregate calculations—like “find all products with more than 5 reviews”—you might reach for a subquery. But Eloquent’s selectRaw and havingRaw combo offers a cleaner, more performant alternative.

    The Pattern

    Instead of a subquery, combine aggregate calculation with filtering in a single query:

    Product::joinRelationship('reviews')
        ->select('products.*')
        ->selectRaw('count(reviews.id) as review_count')
        ->groupBy('products.id')
        ->havingRaw('review_count > 5')
        ->orderByDesc('reviews.id')
        ->take(50)
        ->pluck('review_count', 'products.id');

    This query:

    1. Joins the relationship (using a package like kirschbaum-development/eloquent-power-joins)
    2. Selects all product columns
    3. Adds a calculated column via selectRaw
    4. Groups by product ID
    5. Filters on the aggregate using havingRaw
    6. Sorts and limits results

    Why This Works Better Than Subqueries

    Readability: The query reads top-to-bottom like natural language: “join reviews, count them, group by product, keep only products with count > 5”

    Performance: Single query execution instead of a nested subquery that MySQL has to materialize separately

    Flexibility: Easy to add multiple aggregates (count, sum, avg) and filter on any of them

    Understanding HAVING vs WHERE

    The key difference:

    • WHERE filters rows before grouping (operates on individual records)
    • HAVING filters after grouping (operates on aggregate results)

    You can’t use WHERE review_count > 5 because review_count doesn’t exist yet—it’s calculated during aggregation. That’s where havingRaw comes in.

    The havingRaw Caveat

    Note that we’re using havingRaw, not having. Laravel’s having method expects specific arguments and doesn’t support this pattern cleanly. With havingRaw, you write the HAVING clause exactly as you would in SQL:

    ->havingRaw('review_count > 5')
    ->havingRaw('SUM(amount) > 1000')
    ->havingRaw('AVG(rating) >= 4.0')

    Combining Multiple Aggregates

    You can add multiple calculated columns and filter on any of them:

    Product::joinRelationship('reviews')
        ->select('products.*')
        ->selectRaw('count(reviews.id) as review_count')
        ->selectRaw('avg(reviews.rating) as avg_rating')
        ->groupBy('products.id')
        ->havingRaw('review_count > 5')
        ->havingRaw('avg_rating >= 4.0')
        ->get();

    This finds products with at least 6 reviews AND an average rating of 4.0 or higher—all in one query.

    When to Use This Pattern

    Reach for selectRaw + havingRaw when you need to:

    • Filter on counts, sums, averages, or other aggregates
    • Include the aggregate value in your results (useful for display or sorting)
    • Avoid subquery complexity while keeping queries readable

    It’s a powerful pattern that keeps your Eloquent queries expressive while generating efficient SQL.

  • Using Laravel Tinker for Safe Production Data Creation

    When you need to manually create data in production, Laravel Tinker provides a safe, interactive way using Eloquent models. Instead of writing database migrations for one-off data, you can use Tinker’s REPL to create records with full Eloquent features.

    The Single-Line Pattern

    Format your Tinker commands as single lines (no newlines) for easy paste execution in the production console:

    $city = new \App\Models\City(); $city->name = 'Springfield'; $city->state_id = 42; $city->save(); echo "City created: ID {$city->id}, Slug: {$city->slug}\n";

    This pattern gives you:

    • Instant feedback — Echo the ID and auto-generated slug to confirm success
    • Eloquent features — Automatic slugging, timestamps, events, and model hooks all fire
    • Easy execution — Copy/paste into php artisan tinker without line break issues
    • Audit trail — Terminal output serves as a record of what was created

    When to Use This Approach

    Tinker is ideal for production scenarios where you need to:

    • Create reference data (cities, categories, tags) that’s missing
    • Fix data relationships that require model logic to execute correctly
    • Test a create flow before building a full admin interface
    • Handle urgent production issues without deploying code

    Why Not Raw SQL?

    While you could use DB::insert(), using Eloquent in Tinker means:

    • Auto-generated fields (slugs, UUIDs) are handled automatically
    • Model events and observers fire (useful for audit logs, cache clearing, etc.)
    • Relationships can be attached using Eloquent methods
    • Validation and mutators apply if defined in your model

    For hierarchical data (like cities belonging to states, which belong to countries), Tinker lets you create the entire chain while respecting foreign key constraints and model logic.

    Pro Tips

    Save the commands: Keep a text file of successful Tinker commands for documentation and future reference.

    Use transactions: For multi-step operations, wrap commands in DB::transaction(function() { ... }) within Tinker.

    Check before creating: Always verify the record doesn’t exist first: City::where('name', 'Springfield')->exists()

    Tinker is a powerful tool for production data management when used carefully. Keep your commands single-line, echo confirmations, and you’ll have a safe, traceable way to handle one-off data needs.

  • Safely Update Environment Variables Across Multiple Production Servers

    The Check-Update-Verify Pattern

    When you need to update configuration values like API credentials across multiple production servers, a systematic approach prevents costly mistakes. Here’s a three-step pattern that gives you full visibility and confidence:

    Step 1: Check Current State

    Before making changes, audit what’s currently deployed across all servers. Use SSH with grep to read environment variables:

    ssh web01 "grep '^API_KEY=' /var/www/app/.env"
    ssh web02 "grep '^API_KEY=' /var/www/app/.env"
    ssh api01 "grep '^API_KEY=' /var/www/app/.env"
    

    This reveals discrepancies immediately. You might discover that some servers already have the updated value, or that different servers are using different credentials entirely.

    Step 2: Update with Precision

    Use sed to make surgical changes without touching other configuration values:

    ssh web01 "sed -i 's/^API_KEY=.*/API_KEY=\"sk_live_abc123xyz\"/' /var/www/app/.env"
    ssh web02 "sed -i 's/^API_KEY=.*/API_KEY=\"sk_live_abc123xyz\"/' /var/www/app/.env"
    ssh api01 "sed -i 's/^API_KEY=.*/API_KEY=\"sk_live_abc123xyz\"/' /var/www/app/.env"
    

    The caret (^) anchor is crucial here—it ensures you only match lines that start with API_KEY=, preventing accidental modifications elsewhere in the file where that string might appear in comments or other contexts.

    Step 3: Verify Success

    Run the same grep command from step 1 across all servers again to confirm consistency:

    ssh web01 "grep '^API_KEY=' /var/www/app/.env"
    ssh web02 "grep '^API_KEY=' /var/www/app/.env"  
    ssh api01 "grep '^API_KEY=' /var/www/app/.env"
    

    All servers should now return identical output. If any server differs, you caught it before it causes problems.

    When to Use This Pattern

    This approach shines when:

    • You don’t have configuration management tools like Ansible or Puppet set up
    • You need to make a one-off change quickly without going through a full deployment pipeline
    • You’re working in an environment where you’re sudoer on production servers
    • The number of servers is small enough that manual SSH is practical (typically under 10-15 servers)

    The pattern trades automation for visibility and control. You see exactly what’s happening at each step, which is valuable when working with sensitive configuration like API credentials or database passwords.

    Pro Tips

    Escape quotes properly: When the value contains quotes, escape them in the sed command: \"value-here\"

    Use a consistent naming pattern: If your .env file has similar variable names like API_KEY, API_KEY_SANDBOX, and PARTNER_API_KEY, the ^ anchor prevents accidentally matching the wrong one.

    Test on one server first: If you’re unsure about the sed syntax, run it on one server, verify the result, then proceed to the others.

    Consider a for loop: For many servers, wrap it in a loop:

    for server in web01 web02 api01; do
        echo "Updating $server..."
        ssh "$server" "sed -i 's/^API_KEY=.*/API_KEY=\"new-value\"/' /var/www/app/.env"
        ssh "$server" "grep '^API_KEY=' /var/www/app/.env"
    done
    

    This pattern isn’t a replacement for proper configuration management, but it’s a pragmatic technique for those moments when you need to make a quick, safe change across a handful of servers without the overhead of a full deployment pipeline.

  • Building Inline Edit UI with Vue.js in Laravel Blade

    Building Inline Edit UI with Vue.js in Laravel Blade

    Inline editing is a great UX pattern – users can edit data right where they see it, without navigating to a separate form. Here’s how to implement it cleanly with Vue.js in a Laravel Blade template.

    The Pattern

    The key is tracking three states: viewing, editing, and saving. Here’s the basic structure:

    new Vue({
        el: '#product-details',
        data: {
            editing: false,
            saving: false,
            form: {
                name: '{{ $product->name }}',
                price: '{{ $product->price }}',
                description: '{{ $product->description }}'
            },
            original: {}
        },
        methods: {
            startEditing() {
                // Store original values for cancel
                this.original = { ...this.form };
                this.editing = true;
            },
            
            cancelEditing() {
                // Restore original values
                this.form = { ...this.original };
                this.editing = false;
            },
            
            async save() {
                this.saving = true;
                
                try {
                    const response = await axios.put(
                        '/api/products/{{ $product->id }}',
                        this.form
                    );
                    
                    // Update local state with server response
                    // This keeps UI in sync without page reload
                    this.form = response.data.product;
                    this.editing = false;
                } catch (error) {
                    alert('Save failed: ' + error.response.data.message);
                } finally {
                    this.saving = false;
                }
            }
        }
    });
    

    The Blade Template

    <div id="product-details">
        <!-- View mode -->
        <div v-if="!editing">
            <strong>Name:</strong> @{{ form.name }}<br>
            <strong>Price:</strong> $@{{ form.price }}<br>
            <strong>Description:</strong> @{{ form.description }}
            
            <button @click="startEditing">Edit</button>
        </div>
        
        <!-- Edit mode -->
        <div v-else>
            <input v-model="form.name" placeholder="Name"><br>
            <input v-model="form.price" type="number" placeholder="Price"><br>
            <textarea v-model="form.description" placeholder="Description"></textarea>
            
            <button @click="save" :disabled="saving">
                @{{ saving ? 'Saving...' : 'Save' }}
            </button>
            <button @click="cancelEditing" :disabled="saving">Cancel</button>
        </div>
    </div>
    

    Key Points

    1. Preserve Original Values: When entering edit mode, clone the form data so cancel can restore it
    2. Update After Save: After successful save, update this.form with the server response. This keeps the UI in sync without refreshing the page
    3. Disable During Save: Disable buttons while saving is true to prevent duplicate submissions
    4. Handle Empty States: Show appropriate messaging when fields are empty (both in view and edit mode)

    The Controller

    public function update(Request $request, Product $product)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
            'price' => 'required|numeric|min:0',
            'description' => 'nullable|string'
        ]);
        
        $product->update($validated);
        
        // Return the updated model
        return response()->json([
            'product' => $product->fresh()
        ]);
    }
    

    Why This Works

    This pattern gives you:

    • Instant feedback (no page reload)
    • Clean cancel behavior (reverts to original values)
    • Server validation (with error handling)
    • Optimistic UI updates (form shows saved data immediately)

    The secret sauce is updating this.form with the server response after save. This ensures your Vue state matches what’s actually in the database, without needing to reload the entire page.

  • Including Polymorphic Parent Models in Laravel API Responses

    Including Polymorphic Parent Models in Laravel API Responses

    When building APIs with polymorphic relationships, you might run into a common pitfall: your API returns the morph pivot data (commentable_type and commentable_id) but not the actual parent model itself.

    The Problem

    Let’s say you have a Comment model that can belong to different types of posts (Articles, Videos, etc.) using a polymorphic relationship:

    // Comment model
    public function post()
    {
        return $this->morphTo();
    }
    

    When you try to include the parent in your API response using ?include=post, you might only get the morph pivot columns instead of the actual post data.

    The Solution

    The key is ensuring your polymorphic relationship is properly defined and that the actual parent model is loaded, not just the pivot data. Here’s how:

    // In your CommentController
    public function index(Request $request)
    {
        $query = Comment::query();
        
        // Support includes via query parameter
        if ($request->has('include')) {
            $includes = explode(',', $request->input('include'));
            
            foreach ($includes as $include) {
                if ($include === 'post') {
                    // Load the actual polymorphic parent
                    $query->with('post');
                }
            }
        }
        
        // Support field selection for included relations
        if ($request->has('fields')) {
            foreach ($request->input('fields', []) as $type => $fields) {
                // Apply sparse fieldsets for each included type
                if ($type === 'articles') {
                    $query->with('post:id,title,slug,published_at');
                }
            }
        }
        
        return $query->get();
    }
    

    Testing Your Endpoint

    Test with various combinations to ensure it works:

    # Basic include
    GET /api/comments?include=post
    
    # Include with field selection
    GET /api/comments?include=post&fields[articles]=title,slug
    
    # Multiple includes
    GET /api/comments?include=author,post&fields[articles]=title
    

    Why This Matters

    When the polymorphic parent isn’t properly loaded, your frontend gets incomplete data. Instead of the article title and content, you’d only see article_id and article_type – forcing additional API calls to fetch the actual data.

    By loading the full parent model, your API becomes more efficient and easier to consume. This follows JSON:API conventions for sparse fieldsets and relationship includes.

  • Extract Nested Closures into Private Methods for Maintainability

    Laravel's collection fluent interface is powerful, but deeply nested closures quickly become unreadable. Here's when and how to refactor.

    ## The Problem: Triple-Nested Closures

    “`php
    return $dates->map(function($date) use ($variants, $allPricing, $defaults) {
    return new DatePrice(
    $variants->map(function($variant, $key) use ($date, $allPricing, $defaults) {
    $match = $allPricing->filter(function($pricing) use ($date, $variant) {
    return $pricing->date === $date && $pricing->variant === $variant;
    })->first();
    return $match?->price ?? $defaults[$key];
    }),
    $date
    );
    });
    “`

    This is doing too much in one statement:

    1. Filtering pricing by date and variant
    2. Falling back to default if not found
    3. Mapping across all variants
    4. Creating the final structure

    ## The Solution: Extract Inner Logic

    Pull the innermost logic into a private method:

    “`php
    private function findPriceForVariantOnDate(
    Collection $allPricing,
    array $variant,
    string $date,
    ?Price $defaultPrice
    ): ?Price {
    return $allPricing
    ->filter(fn($p) => $p->date === $date && $p->matchesVariant($variant))
    ->first()?->price ?? $defaultPrice;
    }
    “`

    Now the main logic becomes clear:

    “`php
    // Restructure: one DatePrice per date, containing prices for all variants
    return $dates->map(function($date) use ($variants, $allPricing, $defaults) {
    return new DatePrice(
    $variants->mapWithKeys(function($variant, $key) use ($date, $allPricing, $defaults) {
    $price = $this->findPriceForVariantOnDate(
    $allPricing,
    $variant,
    $date,
    $defaults[$key]
    );
    return [$key => $price];
    }),
    $date
    );
    });
    “`

    ## When to Extract

    **Extract when:**

    – 3+ levels of nesting
    – Inner logic is reused elsewhere
    – The closure has complex conditionals
    – You need to unit test the inner logic separately

    **Keep inline when:**

    – 1-2 levels of nesting with simple operations
    – The closure is very short (1-2 lines)
    – Extraction would require passing 5+ parameters

    ## Naming Matters

    The extracted method name should read like documentation:

    – `findPriceForVariantOnDate()` – immediately clear what it does
    – `matchesVariant()` – better than `in_array($variant, $pricing->variants)`
    – `getDefaultPrices()` – better than `$fallbacks`

    Future developers (including you) will thank you.

  • Refactoring for Multi-Variant Pricing Support in Laravel

    When evolving a Laravel application to support multiple pricing variants (like product tiers, regions, or customer types), here's a clean refactoring pattern that maintains backward compatibility while enabling complex pricing structures.

    ## The Challenge

    You start with single-variant pricing:

    “`php
    public function fetchPricing($inventory)
    {
    $config = $inventory->api_config_standard;
    $response = $this->client->getPricing($config[“product_id”]);
    return $response->prices;
    }
    “`

    Now you need to support premium, standard, and budget variants – each with different pricing.

    ## The Solution: Reference Extraction Pattern

    Create a helper method that extracts all configured variants:

    “`php
    private function getVariants($inventory): Collection
    {
    return collect($inventory->getAllApiConfigs())
    ->mapWithKeys(fn($config) => [$this->getVariantKey($config) => $config]);
    }

    private function getVariantKey($config): string
    {
    return $config[“tier”] . “_” . $config[“region”];
    }
    “`

    Then refactor your pricing method to loop through all variants:

    “`php
    public function fetchAdvancedPricing($inventory): Collection
    {
    $allPricing = collect();
    $variants = $this->getVariants($inventory);

    foreach ($variants as $variantKey => $config) {
    $response = $this->client->getPricing($config[“product_id”]);

    foreach ($response->dates as $date) {
    $allPricing->push(new DatePrice(
    prices: collect([new VariantPrice($config, $date->price)]),
    date: $date->value
    ));
    }
    }

    // Group dates with same pricing across all variants
    return $allPricing->groupBy(fn($dp) => $dp->date)
    ->map(fn($group) => new DatePrice(
    prices: $group->flatMap(fn($dp) => $dp->prices),
    date: $group->first()->date
    ));
    }
    “`

    ## Default Pricing Fallback

    Some dates might not have pricing for certain variants. Create a fallback:

    “`php
    private function getDefaultPrices($variants, $allPricing): Collection
    {
    $prices = $allPricing->flatMap(fn($dp) => $dp->prices);

    return $variants->map(function($config) use ($prices) {
    return $prices
    ->where(“variant_key”, $this->getVariantKey($config))
    ->sortBy(“price”)
    ->first() ?? new VariantPrice($config, null);
    });
    }
    “`

    ## Benefits

    – **Backward compatible:** Single variant still works (collection of 1)
    – **Flexible:** Add new variants without changing core logic
    – **Clean:** Separation of concerns (extraction, fetching, grouping)

    This pattern works for any multi-dimensional pricing: age groups, membership tiers, regional pricing, seasonal rates, etc.

  • Running Multiple Laravel Mix Hot Reload Servers Simultaneously

    When working on Laravel applications with multiple frontends—think public website, admin dashboard, and client portal—developers often hit a frustrating wall: you can only run one npm run hot process at a time. Switch to working on the admin panel? Restart the entire Node container. Back to the main site? Restart again.

    The culprit? Port conflicts. Every webpack-dev-server instance tries to bind to port 8080 by default, and Docker won’t let two containers claim the same port.

    The Solution: HOT_PORT Environment Variable

    Laravel Mix already supports this use case through the HOT_PORT environment variable. Both your main webpack.mix.js and any project-specific configs check for it:

    // webpack.mix.js
    if (process.env.npm_lifecycle_event === 'hot') {
      mix.webpackConfig({
        devServer: {
          host: '0.0.0.0',
          port: process.env.HOT_PORT || 8080
        }
      });
    }

    This means you can run multiple hot reload servers simultaneously, each on its own port, by defining custom npm scripts:

    // package.json
    {
      "scripts": {
        "hot": "cross-env NODE_ENV=development webpack-dev-server --inline --hot ...",
        "hot:admin": "export HOT_PORT=8081 && cross-env process.env.project='admin' NODE_ENV=development webpack-dev-server --inline --hot ..."
      }
    }

    Docker Setup

    Expose both ports in your docker-compose.yml:

    services:
      node:
        ports:
          - 8080:8080  # Default hot reload
          - 8081:8081  # Admin panel hot reload

    Running Multiple Servers

    Now you can develop on multiple frontends simultaneously:

    # Terminal 1: Default site (port 8080)
    npm run hot
    
    # Terminal 2: Admin panel (port 8081)
    npm run hot:admin

    Each webpack-dev-server watches its own files and serves its own HMR updates. Changes to your public site won’t trigger admin panel rebuilds, and vice versa.

    When This Matters

    This approach shines in monolithic Laravel apps with distinct user interfaces:

    • Customer portal (Vue.js, Tailwind)
    • Admin dashboard (different Vue components, Bootstrap)
    • Partner interface (unique styling, separate Vuex stores)

    Instead of context-switching between npm processes and waiting for container restarts, you keep all your dev servers running. Jump between codebases without losing hot reload state.

    Bonus: Conditional Configs

    Laravel Mix supports loading different webpack configs based on process.env.project:

    // webpack.mix.js
    const project = process.env.project;
    
    if (project) {
      require(`${__dirname}/webpack.mix.${project}.js`);
      return;
    }
    
    // Default config follows...

    This lets you maintain clean, project-specific build configs (e.g., webpack.mix.admin.js, webpack.mix.customer.js) while still leveraging the shared HOT_PORT pattern.

    Key Takeaway

    Laravel Mix’s HOT_PORT support isn’t just a convenience—it’s a workflow multiplier for multi-frontend monoliths. No more container restarts. No more choosing which part of your app gets hot reload. Just parallel dev servers doing what they do best.

  • Debugging Eloquent Relationships with toRawSql() Before Writing Migrations

    You just defined a new Eloquent relationship. Now you need to write the migration. What columns should you create? What should they be named? What foreign keys do you need?

    Stop guessing. Use toRawSql() to see exactly what Eloquent expects before you write a single line of migration code.

    The Problem: Eloquent’s Naming Conventions

    Laravel has conventions for relationship columns:

    • hasMany expects parent_id in the child table
    • morphToMany expects taggable_id and taggable_type
    • hasManyThrough expects specific columns in both intermediate and target tables

    If you get any of these wrong, your queries fail at runtime. The fix: inspect the raw SQL before you migrate.

    Using toRawSql() in Tinker

    Define your relationship in the model:

    // app/Models/Order.php
    public function items()
    {
        return $this->hasMany(Item::class);
    }

    Before writing the migration, open php artisan tinker and run:

    Order::has('items')->toRawSql();

    Output:

    select * from `orders` where exists (
        select * from `items`
        where `orders`.`id` = `items`.`order_id`
    )

    Boom. Eloquent expects an order_id column in the items table. Now you know what to create:

    Schema::create('items', function (Blueprint $table) {
        $table->id();
        $table->foreignId('order_id')->constrained();
        $table->string('name');
        $table->timestamps();
    });

    Example: Polymorphic Relationships

    Let’s say you’re building a tagging system. Products can have tags:

    // app/Models/Product.php
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }

    What columns does this need? Check Tinker:

    Product::has('tags')->toRawSql();

    Output:

    select * from `products` where exists (
        select * from `tags`
        inner join `taggables` on `tags`.`id` = `taggables`.`tag_id`
        where `products`.`id` = `taggables`.`taggable_id`
        and `taggables`.`taggable_type` = 'App\\Models\\Product'
    )

    Now you know:

    • Pivot table must be named taggables
    • It needs tag_id, taggable_id, and taggable_type columns
    • The taggable_type column will store the full model class name

    Write the migration:

    Schema::create('taggables', function (Blueprint $table) {
        $table->id();
        $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
        $table->morphs('taggable'); // Creates taggable_id and taggable_type
        $table->timestamps();
        
        $table->unique(['tag_id', 'taggable_id', 'taggable_type']);
    });

    Debugging Existing Relationships

    If a relationship query is failing, toRawSql() shows you what Eloquent is actually looking for:

    // This query is failing. Why?
    $orders = Order::with('customer')->get();
    
    // Check the raw SQL
    Order::with('customer')->toRawSql();

    Output shows it’s looking for customer_id in the orders table, but your column is named user_id. Fix it in the relationship definition:

    public function customer()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    Advanced: Nested Relationships

    For complex queries with nested eager loading, toRawSql() reveals the entire join chain:

    Order::with('items.product')->toRawSql();

    This shows you all the foreign keys Eloquent expects across the entire relationship chain. Invaluable when working with hasManyThrough or deeply nested relationships.

    Pro Tips

    1. Use toRawSql() before migrate: Define relationships first, inspect SQL, then write migrations. Catches naming mismatches immediately.
    2. Works with any query builder method: whereHas, with, has, withCount—if it generates SQL, toRawSql() shows it.
    3. Copy-paste into a database client: Run the raw SQL directly to test if the schema matches your expectations.
    4. Laravel 9+: In earlier versions, use toSql() instead (shows SQL with ? placeholders).

    Bonus: Debugging Performance Issues

    toRawSql() also helps spot N+1 queries and missing indexes:

    // Are we eager loading properly?
    Product::with('category', 'tags')->toRawSql();
    
    // Check if the query uses the right index
    Product::where('status', 'active')->orderBy('created_at', 'desc')->toRawSql();

    Paste the SQL into EXPLAIN to see if your indexes are being used.

    Stop Guessing, Start Inspecting

    Eloquent’s conventions are predictable, but when you’re dealing with polymorphic relationships, custom foreign keys, or deep nesting, toRawSql() removes all guesswork. Define the relationship, inspect the SQL, write the migration. No runtime surprises.