• Tutorials
  • Obscure but Useful Laravel Features — Part 2

    Views117

    #whenLoaded() — Don't Accidentally Trigger Extra Queries

    The problem. API Resources turn your models into JSON. If your resource references a relationship like $this->comments, Laravel will go fetch those comments from the database — even if you didn't ask for them. Do that inside a loop of 100 posts and you've just fired 100 extra queries (the classic "N+1 problem").

    The fix. whenLoaded() says "only include comments if I already loaded them on purpose." If you didn't eager-load them, the key is simply left out of the JSON.

     1public function toArray($request)
     2{
     3    return [
     4        'id' => $this->id,
     5        'title' => $this->title,
     6        // Only runs if the controller did Post::with('comments')
     7        'comments' => CommentResource::collection($this->whenLoaded('comments')),
     8    ];
     9}
    

    So in your controller, Post::with('comments')->get() includes them; Post::all() quietly skips them. No surprise queries.

    There are siblings: whenCounted() for withCount() totals, and whenAppended() for computed accessor values.

    #createOrFirst() — Avoiding the Duplicate-Row Race

    The problem. A very common pattern: "find this user by email, or create them if they don't exist." The usual tool is firstOrCreate(), and it works fine — until two requests run at the exact same moment. Both check, both see "no user," both insert, and now you have a duplicate (or a crash on a unique-key constraint).

    The fix. createOrFirst() flips the order: it tries to insert first, and if the database rejects it because the row already exists, it quietly fetches the existing one instead. The database's own unique constraint becomes the referee, so there's no gap for a race to sneak through.

     1// Safe even if two requests hit this line simultaneously
     2$user = User::createOrFirst(
     3    ['email' => $email],   // how to find/match
     4    ['name' => $name]      // extra values to set if creating
     5);
    

    Rule of thumb: firstOrCreate() is fine for most things; switch to createOrFirst() anywhere concurrent requests might collide (signup, webhooks, "subscribe" buttons people double-click).

    #toRawSql() — See the Actual Query

    The problem. When you dump a query to debug it, Laravel normally shows the SQL with ? placeholders and the real values in a separate array. You end up mentally matching them up: "okay, the third ? is... true?"

     1// The annoying default
     2['query' => 'select * from "users" where "active" = ? and "age" > ?',
     3 'bindings' => [true, 18]]
    

    The fix. toRawSql() plugs the values straight in, giving you a query you can copy-paste into a database client.

     1User::where('active', true)->where('age', '>', 18)->toRawSql();
     2// select * from "users" where "active" = 1 and "age" > 18
    

    dumpRawSql() dumps it and keeps going; ddRawSql() dumps it and stops execution right there.

    #Http::fake() Sequences — Testing Code That Calls an API Repeatedly

    The problem. Say your code polls an API: "is the job done yet? ...is it done now? ...now?" In a test you don't want to hit the real API, and you need different answers each time — "pending," then "pending," then "complete."

    The fix. A fake sequence is a queue of canned responses. Each call to the API pops the next one off the front.

     1Http::fake([
     2    'api.example.com/*' => Http::sequence()
     3        ->push(['status' => 'pending'], 200)   // 1st call gets this
     4        ->push(['status' => 'pending'], 200)   // 2nd call
     5        ->push(['status' => 'complete'], 200),  // 3rd call
     6]);
     7
     8// Now your polling loop can be tested end-to-end without a network.
    

    #Sleep — Pauses You Can Test Without Actually Waiting

    The problem. Code that waits — retry-with-delay, rate-limit backoff — uses sleep(). But a real sleep(5) in your code means a 5-second-slower test suite every single run, which adds up fast.

    The fix. Laravel's Sleep facade is a drop-in replacement. In normal running it sleeps for real; in tests you "fake" it so it returns instantly, and you can even assert it would have slept the right amount.

     1use Illuminate\Support\Sleep;
     2
     3Sleep::for(5)->seconds();   // real code: actually waits 5s
     4
     5// In a test:
     6Sleep::fake();              // now sleeps are instant
     7// ... run the code ...
     8Sleep::assertSlept(fn ($duration) => $duration->seconds === 5);
    

    Your retry logic stays fully tested, but the test finishes in milliseconds.

    #retry() — Try Again Without Writing the Loop

    The problem. External calls fail intermittently — a flaky connection, a momentary timeout. You want to try a few times before giving up, ideally only retrying the right kind of failure (retry a network blip, but don't retry an "invalid card" error).

    The fix. The retry() helper handles the loop, the delay between attempts, and the "should I even retry this?" decision.

     1retry(
     2    3,                              // try up to 3 times
     3    fn () => $api->charge($order),  // the thing that might fail
     4    100,                            // wait 100ms between tries
     5    fn ($e) => $e instanceof ConnectionException  // only retry network errors
     6);
    

    If all 3 attempts fail, the last exception is thrown so you still find out. The delay argument can also be a closure if you want growing waits (100ms, 200ms, 400ms...).

    #chunkById() — Process Huge Tables Without Skipping Rows

    The problem. When you have too many rows to load at once, you process them in batches with chunk(). But there's a sneaky bug: if you're modifying rows as you go (say, deactivating users), the result set shifts beneath you and chunk() can silently skip records.

    Here's the trap with plain chunk():

     1// BUGGY when you modify the rows you're looping over
     2User::where('active', false)->chunk(200, function ($users) {
     3    $users->each->delete();  // page 2 is now where page 1 was → skips
     4});
    

    The fix. chunkById() walks through using the primary key instead of page numbers, so deleting or changing rows can't make it lose its place.

     1User::where('active', false)->chunkById(200, function ($users) {
     2    $users->each->delete();  // safe — tracks by id, not offset
     3});
    

    Rule of thumb: reading only? chunk() is fine. Writing/deleting as you go? Always chunkById().

    #LazyCollection / cursor() — Big Data Without Eating All Your Memory

    The problem. User::all() loads every user into memory at once. With a million rows, your script runs out of memory and dies.

    The fix. cursor() fetches rows one at a time from the database, keeping only the current one in memory — but you still get the friendly collection methods like filter and each.

     1User::cursor()
     2    ->filter(fn ($u) => $u->isEligible())
     3    ->each(fn ($u) => $u->notify(new Promo));
    

    Think of it as a conveyor belt (one item at a time) instead of dumping the whole warehouse on your desk. The same idea wraps PHP generators via LazyCollection::make(), so you can stream a massive file line-by-line through the collection API.

    #Custom Casts — Turn a Database Column Into a Real Object

    The problem. Some data is really a "thing" with behavior, not just a string — an address, a money amount, a set of coordinates. Normally you'd scatter little accessor/mutator methods around your model to convert back and forth, which gets messy.

    The fix. A custom cast lets you say "this column is an Address object." Laravel converts it to and from the database automatically, and all the address logic lives in the address class where it belongs.

     1// On the model — that's the whole hookup:
     2protected $casts = ['address' => Address::class];
     3
     4// Now in your code:
     5$user->address->fullFormatted();   // it's a real object, with methods
     6$user->address->country;
    

    Behind the scenes a small cast class defines how the object turns into columns and back, but day-to-day you just work with a clean object.

    #sometimes() — Validation Rules That Only Apply Sometimes

    The problem. "A rejection reason is required... but only if the status is 'rejected'." Hard-coding required would force it always; leaving it off never validates it. You end up writing branching validation logic.

    The fix. sometimes() adds a rule conditionally based on the submitted data.

     1$validator->sometimes('reason', 'required|min:10', function ($input) {
     2    return $input->status === 'rejected';   // only then is reason required
     3});
    

    Reads almost like the sentence you'd say out loud.

    #Route::missing() — Friendlier "Not Found" Handling

    The problem. With route-model binding, a URL like /orders/999 automatically 404s if order 999 doesn't exist. Sometimes a bare 404 is the wrong experience — you'd rather redirect them somewhere helpful.

    The fix. missing() lets you decide what happens when the lookup fails, per route.

     1Route::get('/orders/{order}', [OrderController::class, 'show'])
     2    ->missing(fn () => redirect()->route('orders.index')
     3                          ->with('warning', 'That order no longer exists.'));
    

    #Number Helper — Formatting You've Been Doing by Hand

    The problem. Currency, percentages, file sizes, "1.2M" style abbreviations — people reimplement these with number_format() and string glue every time, slightly differently, slightly buggily.

    The fix. It's all built in now.

     1Number::currency(1234.56, 'EUR');          // €1,234.56
     2Number::fileSize(1024 * 1024);             // 1 MB
     3Number::abbreviate(1234567);               // 1.2M
     4Number::percentage(23.456, precision: 1);  // 23.5%
     5Number::forHumans(5400);                   // 5 thousand
    

    It even respects locale, so you can format for German vs. English number conventions.

    #Wrap-Up

    If you only remember a few: use createOrFirst() anywhere double-submits or concurrent requests happen, reach for chunkById() whenever you modify rows mid-loop, lean on Sleep + retry() to make flaky external calls both resilient and testable, and let the Number helper retire all your hand-written formatting. None of these are advanced — they're just the kind of thing you only discover by accident, which is exactly why they're worth collecting.

    profile image of Petar Vasilev

    Petar Vasilev

    Petar is a web developer at Mitkov Systems GmbH. He is fascinated with the web. Works with Laravel and its ecosystem. Loves learning new stuff. Writes with the help of #ai.

    More posts from Petar Vasilev