Blog

tap(), rescue(), optional(), fake() - four Laravel helpers that senior devs use daily but rarely see explained beyond a one-liner. 🚀

Laravel ships with a handful of global helpers that senior developers reach for constantly, yet rarely see documented beyond a one-liner in the official docs. tap(), rescue(), optional(), and fake() each solve a recurring problem elegantly - and knowing when to use them (and when not to) separates developers who fight the framework from those who work with it.

📋 Table of Contents


🔧 app() and resolve() {#app-resolve}

app() with no arguments returns the Application instance - the IoC container itself. With an argument, it resolves a binding from the container. resolve() is a direct alias.

// app/Services/ReportService.php
declare(strict_types=1);

namespace App\Services;

use Illuminate\Contracts\Foundation\Application;

class ReportService
{
    public function containerInstance(): Application
    {
        return app(); // Illuminate\Foundation\Application
    }

    public function resolveService(): MailService
    {
        // All three are identical in behaviour.
        $a = app(MailService::class);
        $b = resolve(MailService::class);
        $c = app()->make(MailService::class);

        return $a;
    }
}

app(), resolve(), and app()->make() are the same operation. Pick one and be consistent across your codebase. resolve() is the most explicit - it signals to the reader that a container lookup is happening.

You can pass constructor parameters to override the container's resolution:

// app/Services/ReportService.php
declare(strict_types=1);

namespace App\Services;

class ReportService
{
    public function buildWithOverride(): PdfExporter
    {
        return resolve(PdfExporter::class, [
            'dpi' => 300,
            'format' => 'A4',
        ]);
    }
}

When to use: bootstrapping code that runs outside a dependency-injection context - Artisan command closures, macros, boot() methods in packages where you cannot type-hint. In application code, prefer constructor injection. Using app() as a service locator inside controllers and services is an antipattern that makes code harder to test and harder to read.


🪝 tap() {#tap}

tap($value, callable $callback) runs $callback with $value as its argument and then returns $value unchanged. The callback exists purely for its side effects.

Before tap():

// app/Services/OrderService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Order;
use App\Events\OrderCreated;

class OrderService
{
    public function create(array $data): Order
    {
        $order = Order::create($data);
        event(new OrderCreated($order));
        return $order;
    }
}

After tap():

// app/Services/OrderService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Order;
use App\Events\OrderCreated;

class OrderService
{
    public function create(array $data): Order
    {
        return tap(Order::create($data), function (Order $order) {
            event(new OrderCreated($order));
        });
    }
}

Both are functionally identical. The tap() version is more valuable when you are in the middle of a chain and need to insert a side effect without breaking the return value flow:

// app/Services/CartService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Cart;
use Illuminate\Support\Facades\Log;

class CartService
{
    public function addItem(Cart $cart, int $productId, int $qty): Cart
    {
        return tap($cart)
            ->addProduct($productId, $qty)
            ->recalculateTotals()
            ->save();

        // tap() with no callback returns a proxy object that forwards
        // method calls to $cart and returns $cart at the end.
    }
}

Real uses: logging before returning a model, dispatching events after saving, auditing state changes mid-pipeline, updating caches without breaking return chains.

tap() is also used heavily inside Laravel's own source - Illuminate\Support\Fluent, collections, and the query builder use it extensively to return $this from setter-style methods.

tap() in collection pipelines:

// app/Services/InvoiceService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Invoice;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

class InvoiceService
{
    public function processOverdue(): Collection
    {
        return Invoice::overdue()
            ->get()
            ->tap(function (Collection $invoices) {
                Log::info('Processing overdue invoices', ['count' => $invoices->count()]);
            })
            ->each(fn (Invoice $invoice) => $invoice->markAsOverdue())
            ->filter(fn (Invoice $invoice) => $invoice->shouldNotify());
    }
}

Collection::tap() is the collection-specific version - it receives the entire collection, lets you observe it without transforming it, and the chain continues unaffected.


🛟 rescue() {#rescue}

rescue(callable $callback, mixed $fallback = null, bool $report = true) executes $callback inside a try/catch. If an exception is thrown, it optionally reports the exception and returns $fallback.

// app/Services/EnrichmentService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Order;
use App\Integrations\TaxApiClient;

class EnrichmentService
{
    public function __construct(
        private readonly TaxApiClient $taxApi,
    ) {}

    public function enrichWithTaxData(Order $order): array
    {
        $taxRate = rescue(
            fn () => $this->taxApi->getRateForCountry($order->country),
            fallback: 0.0,
            report: true,
        );

        return [
            'id'       => $order->id,
            'subtotal' => $order->subtotal_cents / 100,
            'tax_rate' => $taxRate,
            'tax'      => $order->subtotal_cents / 100 * $taxRate,
        ];
    }
}

If the tax API throws a connection exception, the order response still returns - with a zero tax rate and an exception reported to your error tracker.

rescue() vs try/catch: the readability argument is real but contextual. For a single fallback on an isolated call, rescue() is visually cleaner. For complex multi-step flows where you need to handle different exception types differently, a try/catch block is clearer and safer.

// app/Services/CurrencyService.php
declare(strict_types=1);

namespace App\Services;

// Use rescue() - simple, single fallback, report the exception.
public function currentRate(string $from, string $to): float
{
    return rescue(
        fn () => $this->api->getRate($from, $to),
        fallback: $this->cachedRate($from, $to),
        report: true,
    );
}

// Use try/catch - multiple exception types, different handling.
public function exchangeAndRecord(int $amountCents, string $from, string $to): int
{
    try {
        $rate = $this->api->getRate($from, $to);
        $converted = (int) round($amountCents * $rate);
        $this->recorder->log($from, $to, $rate);
        return $converted;
    } catch (ApiRateLimitException $e) {
        throw new ServiceUnavailableException('Currency API rate limit exceeded.', previous: $e);
    } catch (NetworkException $e) {
        Log::error('Currency API unreachable', ['exception' => $e]);
        return $this->convertWithCachedRate($amountCents, $from, $to);
    }
}

Silent fallbacks: pass report: false when you genuinely do not care about the failure - optional feature enrichments, non-critical metadata, or developer tooling.

// app/Http/Resources/UserResource.php
declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'       => $this->id,
            'email'    => $this->email,
            'avatar'   => rescue(fn () => $this->gravatar->url(), null, report: false),
        ];
    }
}

If Gravatar is down, the avatar field is null. No exception reported, no user impact. That is the correct behaviour for optional data.


🔍 optional() {#optional}

optional($value) wraps a value in a proxy that returns null for any method call or property access when $value itself is null, instead of throwing an error.

// app/Http/Resources/OrderResource.php
declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'             => $this->id,
            'discount_value' => optional($this->discount)->formatted_value,
            'discount_code'  => optional($this->discount)->code,
        ];
    }
}

Without optional(), accessing $this->discount->formatted_value when $this->discount is null throws a fatal error. With optional(), it returns null cleanly.

optional() vs the ?-> null-safe operator: PHP 8.0 introduced ?-> for null-safe chaining. For simple single-level access, ?-> is the more idiomatic choice:

// app/Http/Resources/OrderResource.php
declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            // Modern PHP - prefer this for simple access
            'discount_value' => $this->discount?->formatted_value,

            // optional() still wins for multi-call chains on the same nullable
            'discount' => optional($this->discount, function ($d) {
                return [
                    'value' => $d->formatted_value,
                    'code'  => $d->code,
                    'type'  => $d->type->label(),
                ];
            }),
        ];
    }
}

optional() with a callback is its second form: if $value is non-null, the callback runs with $value and its return value is used. If $value is null, null is returned without calling the callback.

When optional() still beats ?->:

  • Chaining multiple method calls on the same nullable without repeating ?-> at every step.
  • Working with custom __get() magic properties on non-model objects that ?-> handles inconsistently.
  • Conditional shape-building where you want a structured null rather than a sequence of ?-> checks.

🎭 fake() {#fake}

fake() returns the Faker\Generator instance that Laravel uses internally in model factories. It is globally available anywhere - not just in factory definitions.

// database/factories/OrderFactory.php
declare(strict_types=1);

namespace Database\Factories;

use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;

class OrderFactory extends Factory
{
    protected $model = Order::class;

    public function definition(): array
    {
        return [
            'reference'     => strtoupper(fake()->bothify('ORD-????-####')),
            'status'        => fake()->randomElement(OrderStatus::cases()),
            'subtotal_cents'=> fake()->numberBetween(500, 50000),
            'currency'      => fake()->randomElement(['GBP', 'EUR', 'USD']),
            'email'         => fake()->safeEmail(),
        ];
    }
}

Using fake() outside factories - seeders:

// database/seeders/ProductSeeder.php
declare(strict_types=1);

namespace Database\Seeders;

use App\Models\Product;
use Illuminate\Database\Seeder;

class ProductSeeder extends Seeder
{
    public function run(): void
    {
        collect(range(1, 50))->each(function (int $i) {
            Product::create([
                'name'        => fake()->words(3, asText: true),
                'sku'         => fake()->bothify('SKU-####-??'),
                'price_cents' => fake()->numberBetween(999, 99900),
                'description' => fake()->paragraph(3),
            ]);
        });
    }
}

Using fake() in tests with realistic data:

// tests/Feature/Api/V1/OrderControllerTest.php
declare(strict_types=1);

namespace Tests\Feature\Api\V1;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_order_with_valid_payload(): void
    {
        $payload = [
            'email'          => fake()->safeEmail(),
            'shipping_name'  => fake()->name(),
            'shipping_line1' => fake()->streetAddress(),
            'shipping_city'  => fake()->city(),
            'shipping_post'  => fake()->postcode(),
            'items'          => [
                ['product_id' => 1, 'qty' => fake()->numberBetween(1, 5)],
            ],
        ];

        $this->postJson('/api/v1/orders', $payload)
            ->assertCreated()
            ->assertJsonPath('data.email', $payload['email']);
    }
}

Locale-specific data: pass a locale string to fake() to get locale-appropriate output:

// database/seeders/CustomerSeeder.php
declare(strict_types=1);

namespace Database\Seeders;

use App\Models\Customer;
use Illuminate\Database\Seeder;

class CustomerSeeder extends Seeder
{
    public function run(): void
    {
        // Polish customers for local market testing
        collect(range(1, 20))->each(function () {
            Customer::create([
                'name'  => fake('pl_PL')->name(),
                'city'  => fake('pl_PL')->city(),
                'phone' => fake('pl_PL')->phoneNumber(),
            ]);
        });
    }
}

fake('pl_PL') returns a Faker instance configured for Polish locale - Polish city names, Polish phone number formats, Polish name patterns.


🔲 Bonus: blank() and filled() {#blank-filled}

blank($value) returns true if the value is null, an empty string, a whitespace-only string, or an empty array. filled() is the inverse.

// app/Services/SearchService.php
declare(strict_types=1);

namespace App\Services;

use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;

class SearchService
{
    public function search(array $filters): Collection
    {
        return Product::query()
            ->when(filled($filters['query'] ?? null), fn (Builder $q, $term) =>
                $q->where('name', 'like', "%{$term}%")
            )
            ->when(filled($filters['category'] ?? null), fn (Builder $q, $cat) =>
                $q->where('category_id', $cat)
            )
            ->get();
    }
}

blank() vs empty(): empty() treats '0' as empty (it is falsy in PHP). blank() does not - blank('0') returns false. This is the practical difference:

// blank() comparison table
blank(null)         // true
blank('')           // true
blank('   ')        // true  ← key difference from empty()
blank([])           // true
blank(0)            // false ← 0 is not blank
blank('0')          // false ← '0' is not blank
blank(false)        // true

filled('')          // false
filled('hello')     // true
filled(0)           // true
filled([1, 2, 3])   // true

When to reach for blank():

  • Validating optional form inputs where a user might submit a whitespace string.
  • Conditional query scopes where an unset filter should be skipped entirely.
  • Anywhere empty() would incorrectly treat '0' or 0 as absent.

✅ Summary

  • app() / resolve() / app()->make() are identical - pick one and stick with it. Prefer constructor injection in application code.
  • tap($value, $fn) runs a side effect and returns the original value unchanged. Reach for it when you need to insert logging, events, or audit calls into a chain without breaking the return type.
  • rescue($fn, $fallback, $report) is a readable single-expression try/catch for cases with one fallback. Use report: false for optional enrichments. Prefer full try/catch when multiple exception types need different handling.
  • optional($value) / $value?-> both handle nullable access. Use ?-> for simple single-level access; use optional() when you need to call multiple methods or build a structure from a nullable.
  • fake() is available everywhere, not just factories. Use locale variants like fake('pl_PL') for market-specific test data.
  • blank() is empty() but correct - it treats whitespace as blank and does not treat 0 or '0' as blank.

These helpers exist because Laravel's design philosophy is to make the common case the easy case. Using them consistently produces code that communicates intent clearly and handles edge cases predictably. The payoff is obvious in code review: less noise, more signal.


Follow me on LinkedIn for more Laravel and DevOps content!

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.