AppServiceProvider — The Backbone of Every Laravel App
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()andboot()and why it matters - How to bind interfaces to implementations in the container
- Activating Laravel’s built-in safety rails (
shouldBeStrict,prohibitDestructiveCommands) - 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:
- Registering service container bindings — telling Laravel “when someone asks for
StripePaymentServiceInterface, give themStripePaymentService“ - Activating safety rails — opt into strict mode, prevent dangerous DB operations in production
- Setting global defaults — pagination style, password rules, URL scheme
- 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:fresh, migrate:reset, db: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 insideregister(). 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
- Laravel Docs: Service Providers
- Laravel Docs: Service Container
- Laravel Docs:
Model::shouldBeStrict()
← Configuration Strategy | Next: Multi-Guard Authentication →
2 Comments