AppServiceProvider — The Backbone of Every Laravel App

AppServiceProvider — The Backbone of Every Laravel App

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 3 of 35 | Level: Beginner–Intermediate | Prerequisites: Configuration Strategy


What You’ll Learn

  • The difference between register() and boot() and why it matters
  • How to bind interfaces to implementations in the container
  • Activating Laravel’s built-in safety rails (shouldBeStrictprohibitDestructiveCommands)
  • Logging slow queries automatically
  • Defining HTTP client macros for external APIs
  • Setting up password rules and other global application defaults

Why AppServiceProvider Matters

AppServiceProvider is the first place Laravel calls after booting. It runs on every single request. This makes it the ideal place for:

  1. Registering service container bindings — telling Laravel “when someone asks for StripePaymentServiceInterface, give them StripePaymentService
  2. Activating safety rails — opt into strict mode, prevent dangerous DB operations in production
  3. Setting global defaults — pagination style, password rules, URL scheme
  4. Wiring up cross-cutting concerns — slow query detection, HTTP macros

What it’s not for: business logic, heavy computations, or anything that should live in a dedicated service.


register() vs boot()

This is the most important distinction:

  • register() — runs early, before the application is fully booted. Use it to bind things into the container. Do not use $this->app->make() here to resolve other bindings — other providers may not have registered their things yet.
  • boot() — runs after all providers have been registered. The app is fully booted. Use it for everything else — listeners, macros, observers, global settings.
final class AppServiceProvider extends ServiceProvider
{
    // register() — container bindings only
    public function register(): void
    {
        $this->app->singleton(
            StripePaymentServiceInterface::class,
            StripePaymentService::class
        );

        $this->app->singleton(
            StatusServiceInterface::class,
            StatusService::class
        );

        $this->app->bind(
            OllamaServiceInterface::class,
            OllamaService::class  // bind() creates a new instance each time
        );
    }

    // boot() — everything else
    public function boot(): void
    {
        // safety rails, macros, global defaults...
    }
}

Interface Bindings — The Right Way to Wire Dependencies

Binding an interface to an implementation is the key to testable, swappable architecture. When a class type-hints the interface, Laravel automatically injects the correct implementation:

// The interface (contract)
// app/Services/Stripe/StripePaymentServiceInterface.php
interface StripePaymentServiceInterface
{
    public function charge(int $amountInCents, string $paymentMethodId): PaymentIntent;
    public function refund(string $chargeId): Refund;
}

// The implementation
// app/Services/Stripe/StripePaymentService.php
final class StripePaymentService implements StripePaymentServiceInterface
{
    public function charge(int $amountInCents, string $paymentMethodId): PaymentIntent
    {
        // real Stripe API call
    }
}

// AppServiceProvider
$this->app->singleton(StripePaymentServiceInterface::class, StripePaymentService::class);

// In a controller or action — receives StripePaymentService automatically
final class CheckoutAction
{
    public function __construct(
        private readonly StripePaymentServiceInterface $stripe
    ) {}
}

In tests, you can swap the binding:

$this->app->bind(StripePaymentServiceInterface::class, FakeStripeService::class);

No changes to the code under test. That’s the payoff.


Safety Rails

These three lines should be in every production Laravel application’s AppServiceProvider::boot():

public function boot(): void
{
    // 1. Strict mode — detect N+1 queries and mass assignment in local/staging
    Model::shouldBeStrict($this->app->isLocal());

    // 2. Prevent DROP TABLE, TRUNCATE, etc. in production
    DB::prohibitDestructiveCommands($this->app->isProduction());

    // 3. Force HTTPS in production
    URL::forceHttps($this->app->isProduction());
}

Model::shouldBeStrict()

When enabled, this throws exceptions for:

  • Lazy loading violations — accessing a relationship that wasn’t eager-loaded (N+1 queries)
  • Mass assignment on unfillable attributes — trying to set attributes not in $fillable
  • Accessing missing attributes — trying to use a property not in the model’s attribute set

In production, you might want to log violations instead of throwing exceptions:

Model::shouldBeStrict($this->app->isLocal());

if ($this->app->isProduction()) {
    Model::handleLazyLoadingViolationUsing(function ($model, string $relation): void {
        logger()->warning(
            'Lazy loading violation: ' . $model::class . '::' . $relation,
            ['model_id' => $model->getKey()]
        );
    });
}

DB::prohibitDestructiveCommands()

This prevents migrate:freshmigrate:resetdb:wipe, and similar commands from running in production. One typo in the terminal won’t wipe your database.


Slow Query Detection

Log queries that take longer than a threshold. Essential for catching performance regressions before they hit users:

public function boot(): void
{
    DB::listen(static function (QueryExecuted $query): void {
        $threshold = 100; // milliseconds

        if ($query->time < $threshold) {
            return;
        }

        // Rebuild the full SQL with bindings substituted
        $bindings = array_map(
            static fn ($b): float|int|string => is_numeric($b) ? $b : "'{$b}'",
            $query->connection->prepareBindings($query->bindings),
        );

        $fullSql = $bindings !== []
            ? vsprintf(str_replace('?', '%s', $query->sql), $bindings)
            : $query->sql;

        Log::channel('query')->warning(
            sprintf('Slow query (%sms): %s', $query->time, $fullSql)
        );
    });
}

This writes to a dedicated query log channel (see Structured Logging). In development you can lower the threshold to 0 to log every query.


HTTP Client Macros

An HTTP macro is a named, pre-configured Http:: client. Instead of building the same base URL, timeout, and token on every call, you define it once and use the short name everywhere:

public function boot(): void
{
    Http::macro('catalogue', fn () =>
        Http::baseUrl(config('services.catalogue.url'))
            ->timeout(30)
            ->withToken(config('services.catalogue.secret'))
    );

    Http::macro('stripe', fn () =>
        Http::baseUrl('https://api.stripe.com/v1')
            ->timeout(10)
            ->withBasicAuth(config('services.stripe.secret'), '')
    );
}

Usage becomes clean and consistent across the entire application:

// Instead of this everywhere:
Http::baseUrl(config('services.catalogue.url'))
    ->timeout(30)
    ->withToken(config('services.catalogue.secret'))
    ->get('/items');

// You write this:
Http::catalogue()->get('/items');

This pattern is especially valuable for microservice architectures where you call 5–10 internal services.


Global Password Rules

Define the password rules once in AppServiceProvider and they apply everywhere Password::defaults() is used in Form Requests:

public function boot(): void
{
    Password::defaults(fn () =>
        Password::min(8)
            ->mixedCase()
            ->numbers()
            ->symbols()
    );
}

In Form Requests:

public function rules(): array
{
    return [
        'password' => ['required', 'confirmed', Password::defaults()],
    ];
}

Change the rules once in AppServiceProvider and every form that uses Password::defaults() gets the update automatically.


Registering Observers

Observers can be registered in AppServiceProvider::boot() instead of in model boot() methods:

public function boot(): void
{
    Order::observe(OrderObserver::class);
    User::observe(UserObserver::class);
    HostingPlan::observe(HostingPlanObserver::class);
}

This keeps your models clean and makes it easy to see all observers at a glance.


Complete Example

Here is a production-ready AppServiceProvider combining everything above:

<?php

declare(strict_types=1);

namespace App\Providers;

use App\Models\Order;
use App\Models\User;
use App\Observers\OrderObserver;
use App\Observers\UserObserver;
use App\Services\Stripe\StripePaymentService;
use App\Services\Stripe\StripePaymentServiceInterface;
use App\Services\Status\StatusService;
use App\Services\Status\Contracts\StatusServiceInterface;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Override;

final class AppServiceProvider extends ServiceProvider
{
    #[Override]
    public function register(): void
    {
        $this->app->singleton(StripePaymentServiceInterface::class, StripePaymentService::class);
        $this->app->singleton(StatusServiceInterface::class, StatusService::class);
    }

    public function boot(): void
    {
        // Safety rails
        Model::shouldBeStrict($this->app->isLocal());
        DB::prohibitDestructiveCommands($this->app->isProduction());
        URL::forceHttps($this->app->isProduction());

        // Log lazy loading violations as warnings in production
        if ($this->app->isProduction()) {
            Model::handleLazyLoadingViolationUsing(function ($model, string $relation): void {
                logger()->warning('Lazy loading: ' . $model::class . '::' . $relation);
            });
        }

        // Slow query detection (100ms threshold)
        DB::listen(static function (QueryExecuted $query): void {
            if ($query->time < 100) {
                return;
            }
            $bindings = array_map(
                static fn ($b): float|int|string => is_numeric($b) ? $b : "'{$b}'",
                $query->connection->prepareBindings($query->bindings),
            );
            $sql = $bindings !== []
                ? vsprintf(str_replace('?', '%s', $query->sql), $bindings)
                : $query->sql;
            Log::channel('query')->warning(sprintf('Slow query (%sms): %s', $query->time, $sql));
        });

        // HTTP macros
        Http::macro('catalogue', fn () =>
            Http::baseUrl(config('services.catalogue.url'))
                ->timeout(30)
                ->withToken(config('services.catalogue.secret'))
        );

        // Global password rules
        Password::defaults(fn () => Password::min(8)->mixedCase()->numbers());

        // Observers
        Order::observe(OrderObserver::class);
        User::observe(UserObserver::class);
    }
}

Key Takeaways

  • register() is for container bindings only. boot() is for everything else.
  • Always bind interfaces to implementations in register() — it makes your code testable and swappable.
  • Model::shouldBeStrict() catches N+1 queries and mass assignment bugs before they reach production.
  • DB::prohibitDestructiveCommands() is a one-liner that prevents catastrophic accidents.
  • HTTP macros (Http::macro()) give you named, pre-configured API clients across the entire app.
  • Password::defaults() lets you update password requirements in one place.

Tips and Gotchas

⚠️ Warning: Never call auth()request(), or any session/cookie helper inside register(). The request lifecycle hasn’t started yet when the container is building itself. These helpers return null or throw exceptions at that stage.

💡 Tip: If your AppServiceProvider::boot() method is getting long, extract private methods (registerSlowQueryDetection()enforceModelStrictness()). The provider itself stays small; the logic stays organized.

🔥 Expert Note: Model::shouldBeStrict() is one of the highest-value lines in a Laravel codebase — it surfaces N+1 queries, missing fillable declarations, and undefined accessor access as exceptions in development rather than silent bugs in production. Enable it unconditionally in non-production environments.

Further Reading


← Configuration Strategy | Next: Multi-Guard Authentication →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments