Skip to main content

Command Palette

Search for a command to run...

Laravel's Lottery Class: The Hidden Gem You're Not Using

Coffee Chat - Episode 5

Updated
β€’7 min read
Laravel's Lottery Class: The Hidden Gem You're Not Using

Picture this: It's 3 AM. Your phone buzzes. Then buzzes again. And again. Your error tracker has gone absolutely mental because your production app decided to report every. single. lazy loading violation. All 47,000 of them. In one hour.

Sound familiar?

Stressed developer gif

Don't worry. Laravel has a hidden gem that most developers walk past every single day. It's been sitting there since version 9.40, quietly waiting to save you from notification hell.

Meet the Lottery class.

Wait, Laravel Has a Lottery?

Yes! And no, Taylor Otwell didn't add gambling to the framework (though that would make stand-ups more interesting).

The Lottery class lives in Illuminate\Support\Lottery and does exactly what it sounds like. It lets you run code based on probability. Think of it as a bouncer for your callbacks. Sometimes they get in. Sometimes they don't.

Here's the simplest example:

use Illuminate\Support\Lottery;

$result = Lottery::odds(1, 5)->choose();

// Returns true 20% of the time, false 80% of the time

The odds() method takes two arguments: winning chances and total chances. So odds(1, 5) means 1 in 5 chance, or 20% probability. Simple maths, powerful results.

But here's where it gets spicy. You can attach callbacks:

Lottery::odds(1, 10)
    ->winner(fn () => logger('πŸŽ‰ You won!'))
    ->loser(fn () => logger('😒 Maybe next time'))
    ->choose();

The winner() callback fires when luck is on your side. The loser() callback runs when it isn't. Finally, choose() rolls the dice and returns whatever your callback returns.

The Real Problem This Solves

Let's get real for a second. Why would you ever want code to run... sometimes?

Here's a scenario every Laravel developer has faced:

You're a responsible developer. You've enabled Model::preventLazyLoading() in production because you read that article about N+1 queries being evil. Good job! But now your Sentry dashboard looks like this:

Explosion gif

Your app handles 500,000 requests daily. Each request triggers 3 lazy loading violations on average. That's 1.5 million error reports. Per day.

Your Sentry bill? Through the roof. Your inbox? Destroyed. Your will to live? Questionable.

But here's the thing. You don't NEED 1.5 million reports to know you have a problem. You just need... some of them. Enough to know the issue exists and where it lives.

Enter the Lottery.

Real World Use Cases (The Fun Part)

Let me show you some scenarios where the Lottery class absolutely shines.

1. The "Please Stop Emailing Me" Slow Query Logger

Your database has some... let's call them "personality traits." Sometimes queries take longer than they should. You want to know about it, but not EVERY time.

use Carbon\CarbonInterval;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Lottery;

// In your AppServiceProvider boot() method
DB::whenQueryingForLongerThan(
    CarbonInterval::seconds(2),
    Lottery::odds(1, 100)->winner(
        fn () => report('🐌 Query exceeded 2 seconds. Time for a coffee break!')
    )
);

Now only 1% of slow queries get reported. You still catch the problem. Your phone stays quiet. Everyone's happy.

Notice something cool here? We're passing the Lottery instance directly to whenQueryingForLongerThan(). No need to wrap it in a closure or call choose(). That's because...

2. The Magic of __invoke()

Magic gif

Here's where my mind was blown when I first discovered this.

The Lottery class implements __invoke(), which means a Lottery instance IS a callable. You can pass it anywhere that accepts a callback.

From the Laravel documentation:

Since the lottery class is callable, we may pass an instance of the class into any method that accepts callables.

This is HUGE. Laravel has tons of methods that accept callables. And the Lottery class slides right in.

3. The "My Error Tracker Bill Is How Much?!" Solution

Let's say you're importing products from a sketchy CSV file your client sent you. (We've all been there.) Some rows will fail. Many rows will fail. You don't need to report ALL of them.

$products = collect($csvRows);
$failedCount = 0;

$products->each(function (array $row) use (&$failedCount) {
    try {
        Product::create($row);
    } catch (ValidationException $e) {
        // Report only 1% of failures to Sentry
        Lottery::odds(1, 100)
            ->winner(fn () => report("Import failed: {$e->getMessage()}"))
            ->choose();

        $failedCount++;
    }
});

logger("Import complete. {$failedCount} rows failed.");

Your CSV has 50,000 bad rows? Instead of 50,000 Sentry events (and a very angry finance team), you get around 500. Still enough to debug the patterns, not enough to bankrupt you.

4. The "Let's A/B Test Without Actually Setting Up A/B Testing" Hack

Need to show a new feature to a percentage of users but don't have time to set up a proper feature flag system? I've got you.

public function index()
{
    return Lottery::odds(10, 100)
        ->winner(fn () => view('dashboard.shiny-new-version'))
        ->loser(fn () => view('dashboard.boring-old-version'))
        ->choose();
}

10% of users see the new dashboard. 90% see the old one. Is this a proper A/B test with analytics? No. Will it work for a quick experiment before your sprint demo? Absolutely.

5. The "Our Analytics Pipeline Is Crying" Data Sampling

Your data science team wants user behavior analytics. Processing all 2 million users would take 3 days. They need results by tomorrow.

User::chunk(1000, function ($users) {
    foreach ($users as $user) {
        // Process roughly 5% of users
        Lottery::odds(5, 100)
            ->winner(fn () => AnalyzeUserBehavior::dispatch($user))
            ->choose();
    }
});

Instead of 2 million jobs, you queue around 100,000. Still statistically significant. Finishes overnight. Data scientists buy you coffee.

6. The "Pizza Party Decider" (Yes, Really)

Okay, this one's just for fun. But imagine this in your Slack bot:

$shouldOrderPizza = Lottery::odds(1, 20)
    ->winner(fn () => "πŸ• PIZZA FRIDAY IS TODAY! Order now!")
    ->loser(fn () => "No pizza today. Back to work.")
    ->choose();

$slack->send('#general', $shouldOrderPizza);

5% chance of pizza every Friday. The suspense keeps morale high. HR loves you.

Testing Lottery-Based Code (Without Going Crazy)

Testing gif

"But wait," I hear you say. "How do I test code that's literally random?"

Great question! Laravel thought of this. The Lottery class comes with testing utilities that make randomness, not random.

Force a Win Every Time

use Illuminate\Support\Lottery;

public function test_new_dashboard_renders_for_lottery_winners()
{
    Lottery::alwaysWin();

    $response = $this->get('/dashboard');

    $response->assertViewIs('dashboard.shiny-new-version');
}

Force a Loss Every Time

public function test_old_dashboard_renders_for_lottery_losers()
{
    Lottery::alwaysLose();

    $response = $this->get('/dashboard');

    $response->assertViewIs('dashboard.boring-old-version');
}

Predetermine a Sequence

Need more control? You can script exactly what happens:

// First call wins, second loses, third wins, then random
Lottery::fix([true, false, true]);

Lottery::odds(1, 2)->choose(); // true (first in sequence)
Lottery::odds(1, 2)->choose(); // false (second in sequence)  
Lottery::odds(1, 2)->choose(); // true (third in sequence)
Lottery::odds(1, 2)->choose(); // random (sequence exhausted)

Reset to Normal Behavior

Always clean up after yourself:

protected function tearDown(): void
{
    Lottery::determineResultsNormally();
    parent::tearDown();
}

This prevents test pollution. Nobody wants their tests failing because a previous test left the Lottery rigged.

Quick API Reference

Here's everything the Lottery class offers:

MethodWhat It Does
Lottery::odds($chances, $outOf)Creates a new Lottery with the given probability
->winner(callable $callback)Sets the callback that runs when lottery wins
->loser(callable $callback)Sets the callback that runs when lottery loses
->choose()Executes the lottery and returns the result
Lottery::alwaysWin()Forces all lotteries to win (testing)
Lottery::alwaysLose()Forces all lotteries to lose (testing)
Lottery::fix(array $sequence)Sets a predetermined sequence of results
Lottery::determineResultsNormally()Resets to random behavior

When NOT to Use Lottery

Let's be honest. The Lottery class isn't a silver bullet. Here's when you should reach for something else:

Feature Flags: If you need to know WHICH users saw WHAT, use Laravel Pennant or a proper feature flag system. Lottery is random per request, not per user.

A/B Testing with Analytics: Same deal. If you need conversion metrics, you need to track cohorts. Lottery won't remember who saw what.

Critical Error Reporting: If an error MUST be reported (payment failures, security issues), don't gamble with it. Some things are too important.

Rate Limiting: If you need "X requests per minute", use Laravel's rate limiter. Lottery is probabilistic, not deterministic.

The Elegance Factor

What I love about the Lottery class is how it transforms ugly code into something readable.

Before (the old way):

if (random_int(1, 100) <= 5) {
    report($exception);
}

After (the Laravel way):

Lottery::odds(5, 100)
    ->winner(fn () => report($exception))
    ->choose();

The intent is crystal clear. Anyone reading this code immediately understands: "This reports 5% of exceptions."

And when you combine it with Laravel's callback-accepting methods:

DB::whenQueryingForLongerThan(
    CarbonInterval::seconds(2),
    Lottery::odds(1, 100)->winner(fn () => report('Slow query detected'))
);

No if statements. No manual random number generation. Just clean, fluent code that reads like English.

Wrapping Up

Success celebration gif

The Lottery class has been in Laravel since version 9.40, and it's still one of the framework's best-kept secrets. If you're dealing with high-volume events that need sampling, give it a try.

Next time you find yourself writing if (random_int(1, 100) <= 5), stop. Laravel has a better way.

Your error tracker will thank you. Your phone will thank you. And your 3 AM self will definitely thank you.

Found this helpful? Follow me for more Laravel tips and deep dives into features you probably didn't know existed.

Cheerio!