The provider named in module.json is the one place the platform calls into your plugin. Knowing when each method runs tells you where every registration belongs.
Extend Nwidart\Modules\Support\ModuleServiceProvider. You must declare two properties (a LogicException is thrown otherwise):
class TestimonialsServiceProvider extends ModuleServiceProvider
{
protected string $name = 'Testimonials'; // folder / StudlyCase name
protected string $nameLower = 'testimonials'; // alias: config, view and lang namespace
protected array $commands = []; // Artisan command classes to register
protected array $providers = [RouteServiceProvider::class]; // child providers, registered in register()
}parent::boot() then performs, in this order:
registerCommands() — the classes in $commands.registerCommandSchedules() — calls your configureSchedules(Schedule $schedule) after the app has booted.registerTranslations() — override it (see below).registerConfig() — every PHP file in config/ is merged into config('alias') (config.php) or config('alias.file'), unless the config is cached.registerViews() — the resources/views folder under the namespace alias:: (publishable overrides in resources/views/modules/alias), and the Blade class-component namespace Modules\{Name}\View\Components as <x-alias::component-name>.loadMigrationsFrom() — database/migrations, so php artisan migrate picks them up.register() only registers the child providers listed in $providers.
| Method | Runs | Put here |
|---|---|---|
register() | Every request, as providers register | parent::register(), mergeConfigFrom(), container bindings and singletons. Do not resolve other services here: they may not be registered yet |
boot() | Every request, after all providers registered | parent::boot(), translations override, breadcrumbs, dashboard rows, Shortcode::..., Widgets::register(), BindingSourceRegistry sources, AppPages::register(), Blade components, model/event listeners, view composers, observers, cache-invalidation hooks, macros |
Event::listen(AdminBooting::class, ...) | Only on admin requests, after the core menu and settings are registered | MenuManager::make(), SettingsManager::add(), MenuBuilder::register() — anything that fills an admin-only registry or depends on the signed-in user |
configureSchedules() | When the scheduler or schedule:list boots | $schedule->command('alias:task')->hourly()->withoutOverlapping(); |
Child RouteServiceProvider::map() | Route registration (not when routes are cached) | Route groups with their middleware |
Child EventServiceProvider | Boot | $listen maps for your own and third-party events (see Booking's) |
AdminBooting, others in boot()AdminTheme middleware. During boot() the session has not started, so Auth::user() is still null: permission checks would fail, and Navigation::register() (which adds the settings sidebar link) only acts when Auth::check() is true. Registering there would also cost time on every public request.AdminTheme. Register them in boot().boot() — the bundled modules do so — because they are only descriptions (a class name and an optional permission); the permission is evaluated when the dashboard renders, and the registry also feeds the visitor account dashboard.AdminBooting listenerAuth::user()) — check permissions and register only what the user may use.pages as a closure so definitions are built only when the screen is opened.TestimonialsMenu::register(), TestimonialsSettings::register()) so the provider stays readable — the bundled modules do exactly this.registerTranslations()The base implementation calls loadTranslationsFrom($path) without a namespace, so lang/en/testimonials.php would only be reachable as __('testimonials.menu') and could clash with core keys. Every bundled module overrides it:
protected function registerTranslations(): void
{
$langPath = lang_path('modules/' . $this->nameLower); // site-level overrides win
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->nameLower);
$this->loadJsonTranslationsFrom($langPath);
return;
}
$moduleLangPath = module_path($this->name, 'lang');
$this->loadTranslationsFrom($moduleLangPath, $this->nameLower);
$this->loadJsonTranslationsFrom($moduleLangPath);
}This also lets a site owner override your strings by copying them to lang/modules/testimonials/… without editing the plugin.
$this->app->booted(function (): void {
// runs once after every provider (including other plugins) has booted
});boot()php (condensed from BookingServiceProvider)
app(BindingSourceRegistry::class)->register(new ServiceBindingSource);
AppPages::registerMany([
PageDefinition::make('booking.services')
->label(static fn (): string => __('booking::booking.services'))
->selectedBySetting('booking_page_services')
->pageSetup(__('booking::booking.app_page_services'), __('booking::booking.app_page_services_help'))
->pageSetupGroup(__('booking::booking.booking'))
->route('booking.services.index'),
]);ResponseCacheInvalidator::flushWhenChanged([Testimonial::class]); // saved / deleted / restored
// or, for models that extend App\Models\BaseModel, nothing at all: ClearCache already does it.Booking and TruCommerce keep heavy reference data (services, taxonomies, countries) in a versioned cache service (BookingReferenceCache, StorefrontReferenceCache) that every relevant model saved/deleted event invalidates, together with the response cache. Register the configured media model (config('media-library.media_model')), not the base Spatie class, because Eloquent events fire per concrete class.
The complete, tested provider is shown in the tutorial. Its shape:
TestimonialsServiceProvider
register() parent::register(); mergeConfigFrom('config/config.php' -> config('testimonials'))
boot() parent::boot();
registerTranslations(); // override, namespaced
registerBreadcrumbs(); // require routes/breadcrumbs.php
registerDashboard(); // Dashboard::admin()->row(...)->widget(...)
registerPageBuilderParts();// Blade::component + Shortcode::category/registerIfMissing
Widgets::register('testimonials-list', ...);
Event::listen(AdminBooting::class, fn () => [menu, settings]);
RouteServiceProvider::map() admin routes with web, auth, verified, 2fa, AdminThemeparent::boot() — views, config and migrations silently stop loading.boot() instead of on AdminBooting.setting() in register() on a fresh install: the settings table may not exist yet. Guard with config('dotartisan.installed') (the core does) or defer to boot()/AdminBooting.boot(): it executes on every request, including installer and artisan commands. Use lazy closures.php artisan route:clear.