Category: Laravel

  • Debugging Missing Records in Laravel Reports: Export the SQL

    When users report “missing records” in generated reports, resist the urge to dive into application logic first. Instead, export the exact SQL query your Laravel app is executing and inspect it directly. Nine times out of ten, the issue is in the query, not your code.

    The Problem

    A user reports that your cancellation report shows only 6 out of 8 expected records. You’ve checked the database manually—all 8 records exist with correct timestamps and status codes. But the report consistently omits 2 specific records. What’s going wrong?

    The Solution: Export and Inspect Raw SQL

    Don’t guess. Export the generated SQL and run it yourself:

    // In your report controller
    public function generateReport(Request $request)
    {
        $query = $this->buildCancellationReportQuery($request->filters);
        
        // Export for debugging
        \Storage::put('temp/debug-query.sql', $query->toSql());
        \Storage::put('temp/debug-bindings.json', json_encode($query->getBindings()));
        
        $results = $query->get();
        
        return Excel::download(new CancellationReportExport($results), 'report.csv');
    }
    

    Then examine the SQL file. Replace placeholders with actual bindings and run it directly in your database client.

    What to Look For

    • JOIN mismatches: Are all necessary tables joined? Check LEFT vs INNER JOINs.
    • WHERE clause conflicts: Multiple ANDs can exclude records unintentionally.
    • UNION logic errors: If your query combines multiple subqueries, each needs identical filtering.
    • Date/time timezone issues: Server timezone != user timezone can shift filter boundaries.
    • Soft delete confusion: Are you accidentally filtering out records with deleted_at IS NULL?

    Real Example

    In one case, a report combined 3 UNION subqueries to gather records from different sources. The main query filtered by supplier_id = 13088, but only 2 of the 3 UNION branches had this filter. The missing records came from the third branch—which returned ALL suppliers’ data, then got filtered out at the GROUP BY stage.

    The fix: add WHERE supplier_id = 13088 to every UNION branch.

    Pro Tips

    • Use DB::enableQueryLog() + DB::getQueryLog() for quick debugging in development
    • Laravel Telescope automatically captures all queries—invaluable for production debugging
    • For complex reports, consider writing raw SQL first, then translating to Query Builder once it works

    This approach saves hours of tracing through service layers, repositories, and scopes. When records are missing, go straight to the source: the SQL.

  • Advanced Laravel Validation: Conditional Rules with Custom Closures

    When building complex forms in Laravel, you’ll often need validation rules that depend on other input values. Laravel’s Rule::requiredIf() combined with custom closure validation gives you powerful control over conditional logic.

    The Challenge

    Imagine you’re building a file upload system where users can choose between uploading files or entering barcodes. The validation rules need to change based on that choice—files are required for one mode, barcodes for another. Hardcoding separate validation paths leads to duplication and brittle code.

    The Solution: Conditional Rules with Closures

    Laravel lets you build validation rules dynamically using Rule::requiredIf() for conditional requirements and custom closures for complex business logic:

    use Illuminate\Validation\Rule;
    
    $uploadType = $request->input("items.{$itemId}.upload_type");
    
    $rules = [
        "items.{$itemId}.upload_type" => 'required|in:file,barcode',
        
        // Files only required when upload_type is 'file'
        "items.{$itemId}.files" => [
            "required_if:items.{$itemId}.upload_type,file",
            'array',
            function ($attribute, $value, $fail) use ($itemId, $maxAllowed) {
                if (count($value) > $maxAllowed) {
                    $fail("Item {$itemId}: Too many files. Max allowed: {$maxAllowed}");
                }
            }
        ],
        
        // Barcodes only required when upload_type is 'barcode'
        "items.{$itemId}.barcodes" => [
            "required_if:items.{$itemId}.upload_type,barcode",
            'string',
            function ($attribute, $value, $fail) use ($repository, $itemId) {
                $codes = array_filter(preg_split('/[\s\n]+/', $value));
                
                // Check for duplicates in database
                $duplicates = $repository->findExisting($codes);
                if ($duplicates->isNotEmpty()) {
                    $fail("Item {$itemId}: Duplicate barcodes found: " . $duplicates->implode(', '));
                }
            }
        ],
    ];
    
    $validated = $request->validate($rules);
    

    Why This Pattern Works

    • Centralized validation: All rules in one place, no scattered if/else branches
    • Flexible conditions: Rule::requiredIf() handles simple dependencies
    • Custom business logic: Closures let you inject services and run complex checks
    • Clear error messages: Customize failures per field and context

    Taking It Further

    You can nest conditions deeper with Rule::when() or combine multiple closure validators for different aspects (format validation, uniqueness, business rules). Laravel’s validation system is expressive enough to handle even the most complex form requirements without leaving the validation layer.

    Pro tip: For very complex validation, consider extracting to a custom Form Request class. But for moderately complex interdependent fields, this inline approach keeps everything readable and maintainable.

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

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

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