Skip to content

Modules

New functionality belongs in a module, not the root app/. A module is an in-repo Composer package under app-modules/, wired through a path repository in the root composer.json (app-modules/*, symlinked), and auto-loaded by internachi/modular.

app-modules/automations/
composer.json package name, deps, autoload, service provider
ARCHITECTURE.md working notes, kept current
config/ optional: <module>.php, merged in the provider
database/
factories/ migrations/ seeders/
resources/views/
routes/ optional
src/
AutomationsFilamentPlugin.php
Console/Commands/
Filament/Resources/ Filament/Pages/ Filament/Clusters/ Filament/Widgets/
Jobs/ Listeners/ Models/ Policies/
Providers/AutomationsServiceProvider.php
tests/Feature/ tests/Unit/

src/ maps to WebMoves\<Module>\: app-modules/catalog/src/Models/Product.php is WebMoves\Catalog\Models\Product. Tests, factories and seeders have their own namespaces (WebMoves\Automations\Tests\, ...\Database\Factories\, ...\Database\Seeders\), declared in the module’s composer.json.

A module’s composer.json must:

  • declare "version": "1.0". Without it Composer takes the version from the git branch (dev-<branch>) and lock-file resolution breaks on branch switches
  • list its service provider under extra.laravel.providers, so Laravel discovers it. contacts and simple-filament-layout have none, since they only provide classes
  • require everything it uses, other modules and packages alike. tests/Unit/ModuleDependencyTest.php fails otherwise, and also on a dependency cycle

The current graph is on the architecture page. When you add a package for a module, update only what you need: ddev composer update web-moves/<module> <package>, without -W, which would upgrade the whole stack.

These come from vendor/internachi/modular/src/Plugins/. Don’t also register them by hand: a listener registered twice fires twice.

You add Found at What happens
Views resources/views View namespace named after the module folder: view('assistant::topbar-button')
Blade components src/View/Components Components prefixed with the module name
Migrations database/migrations Run by migrate
Factories database/factories Found for the module’s models
Commands src/Console/Commands Registered with Artisan
Listeners src/Listeners Event discovery, on by default here (config/app-modules.php leaves should_discover_events null)
Policies src/Policies/<Model>Policy.php for src/Models/<Model>.php Registered on the Gate by name
Routes routes/*.php Required as they are, with no middleware group applied
Translations resources/lang Namespaced by the module name

Things that don’t fit the convention need wiring in the service provider:

  • Config is not merged. Keep mergeConfigFrom in register():

    $this->mergeConfigFrom(__DIR__.'/../../config/catalog.php', 'catalog');
  • Commands elsewhere than src/Console/Commands, as in content-management, whose commands live by feature (src/Sites/Console/Commands/) and are listed in $this->commands([...]).

  • Policies for models that aren’t in this module’s src/Models, such as a vendor model: Gate::policy(Role::class, RolePolicy::class).

  • The schedule. A module adds its entries itself:

    $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
    $schedule->command('automations:run-due')->everyMinute()->withoutOverlapping();
    });

Create a module’s files with the framework’s generators and --module, for example ddev artisan make:model Foo --module=catalog.

Most modules have a plugin class directly under src/, extending WebMoves\Utils\Filament\ModuleFilamentPlugin, and it is usually empty:

class AutomationsFilamentPlugin extends ModuleFilamentPlugin {}

The base class discovers src/Filament/Resources, Clusters, Pages and Widgets by folder, so a new resource in the right folder appears without being listed. A subclass can turn one off ($discoverWidgets = false) or add to register(): panel routes, as in SalesFilamentPlugin, or render hooks, as in AssistantFilamentPlugin.

The plugin itself is registered once, in AdminPanelProvider::panel():

->plugins([
CustomFieldsFilamentPlugin::make(),
ContentManagementFilamentPlugin::make(),
// ...

The customer panel is different: CustomerPanelProvider lists its pages, resources and widgets explicitly, and is itself a discovered provider of the customer-panel module.

Filament only checks a policy if there is one, so a resource without a policy is open to every staff role. Every resource needs one in its module:

namespace WebMoves\Automations\Policies;
class AutomationRulePolicy extends CapabilityPolicy
{
protected string $capability = 'automations.manage';
}

A custom page that isn’t a resource is open to everyone on the panel unless it overrides canAccess(). See Authorization.

  1. ddev artisan make:module <name> scaffolds the folder, including a service provider and "version": "1.0", under the WebMoves namespace that config/app-modules.php sets.
  2. Make sure web-moves/<name> is in the root composer.json’s require.
  3. ddev composer update web-moves/<name>.
  4. If it has admin screens, add a plugin class and register it in AdminPanelProvider.
  5. Add an ARCHITECTURE.md. Add a line to the dependency table.

ddev artisan modules:sync only updates phpunit.xml (the Modules test suite glob) and PhpStorm config files. It does not touch Composer state or Laravel’s package cache.

  1. Remove it from the root composer.json’s require.
  2. Delete the module’s directory.
  3. ddev composer update web-moves/<name> --no-scripts, which drops it from composer.lock and vendor/composer/installed.json.
  4. ddev composer dump-autoload.

A bare dump-autoload alone won’t refresh installed.json. That leaves bootstrap/cache/packages.php pointing at the deleted provider, and the next artisan boot fails with “Class not found”.