
Aureus ERP is a modular platform built with Laravel and Filament, where features such as Products, Sales, Purchases, Inventory, Accounting, and Manufacturing are developed as independent plugins. This modular architecture makes the system flexible, scalable, and easy to extend.
As Aureus ERP grew, multiple plugins duplicated product resources, leading to inconsistent code and UI. We unified them into a single extensible Product Resource using Laravel dynamic relationships and a lightweight schema registry.
The Problem
A Product isn’t owned by one plugin, Sales quotes it, Purchases buys it, Inventory stocks it, and Accounting taxes it. So each plugin shipped its own ProductResource (the Filament class defining the product form, table, and relations).
The result: more than ten near-identical ProductResource classes.
Every small change in a new field, a fixed label had to be repeated in all of them. Miss one and the plugins disagree about what a product looks like. The sharp version: Accounting needs to add tax fields to the product form, but it has no business editing the Products plugin’s files.
So the question was: how can each plugin contribute fields and relationships to one shared resource, without editing the base model or duplicating the form?
Why Traditional Approaches Didn’t Work
- Inheritance in PHP allows only one parent, but a product form may need fields from Accounting, Inventory, and Manufacturing at once.
- Editing the base directly defeats the point of plugins; the Products plugin would have to know about every other plugin.
- One mega-form with every field leaves dead UI behind whenever a plugin isn’t installed.
Each forces plugins to know about each other. We wanted the reverse: let each plugin announce what it adds, and have the shared resource collect those announcements at runtime.
Understanding Laravel Dynamic Relationships
Normally, a relationship is a method in the model. But here the relationship (productTaxes) and the model (Product) live in different plugins, and we won’t edit Product from Accounting.
Laravel’s resolveRelationUsing attaches a relationship at runtime, from anywhere its docs recommend it for exactly this (package development):
|
1 2 3 |
Order::resolveRelationUsing('customer', function (Order $order) { return $order->belongsTo(Customer::class, 'customer_id'); }); |
After this, $order->customer works without Order ever declaring the method. One rule: always pass explicit key names — there’s no method for Laravel to inspect, so conventions can’t kick in.
How Aureus ERP Solved It
Two parts, both based on contribution, each plugin declares what it adds from its own service provider.
1. Extend the model. Each plugin adds its relationships in boot():
|
1 2 3 4 |
// AccountingServiceProvider::boot() Product::resolveRelationUsing('productTaxes', fn (Product $product) => $product->belongsToMany(Tax::class, 'accounts_product_taxes', 'product_id', 'tax_id') ); |
The base Product stays clean, and productTaxes only exists when Accounting is installed. Inventory, Manufacturing, and future plugins extend it in the same way.
2. Extend the form with a schema registry. Instead of each plugin overriding form(), the form is built once, and plugins register small “modifiers” that adjust it like middleware for a form.
Schema Registry Explained
Aureus ERP ships a SchemaRegistry (Support plugin):
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class SchemaRegistry { public static function register( string $resourceClass, string $package, Closure|callable|string|array $modifier, int $priority = 100, string $type = 'form' ): void { static::$modifiers[$resourceClass][$package] = [ 'modifier' => $modifier, 'priority' => $priority, 'type' => $type, ]; } } |
A plugin registers its contribution at boot:
|
1 2 3 |
// Accounting adds its tax fields to the shared ProductResource form SchemaRegistry::register(ProductResource::class, 'accounts', [AccountProductSchema::class, 'taxFields'], priority: 20); |
The single base resource builds its form, then applies whatever plugins contributed, sorted by priority, so fields land predictably:
public static function form(Schema $schema): Schema
|
1 2 3 4 5 |
public static function form(Schema $schema): Schema { $schema = static::baseForm($schema); // fields every plugin shares return SchemaRegistry::applyModifiers($schema, static::class, 'form'); } |
The dependency points one way: Products expose the registry; every other plugin registers into it. Add a new plugin tomorrow, and it slots in with one register() call; the core never changes.
Before vs After
Before Accounting shipped its own ProductResource with a ~160-line form() override that rebuilt the entire product form just to add a few tax fields. When the base form changed, the copy silently drifted out of sync. 10 plugins, 10 copies.
After One ProductResource, one form. Accounting deletes its override and keeps only its navigation; its tax fields arrive through a single register() call, and its relations through resolveRelationUsing().
Challenges and Lessons Learned
Dynamic relationships aren’t real methods, so method_exists($product, ‘productTaxes’) returns false even though $product->productTaxes works. Some Filament internals probe relations with method_exists. For those, we declare a real method via a plugin-owned trait instead of the dynamic registration, keeping the plugin decoupled while satisfying the check.
The same “only when installed” rule applies to pages:
|
1 2 3 |
if (Package::isPluginInstalled('accounts')) { $items[] = ManageBankAccounts::class; } |
Benefits
- One form and table per entity, no drift between copies.
- No field collisions priorities decide order; each plugin owns its own slice.
- No broken references exist. ProductResource::class usages stay valid.
- Less duplication and debt, a shared change happens in exactly one place.
- Easier future plugins: a new plugin extends Product with a couple of register() / resolveRelationUsing() calls; the core stays untouched.
Final Thoughts
The trap most modular systems fall into is sharing a concept by copying it. By inverting the dependency, letting each plugin contribute to a shared resource instead of duplicating it, Aureus ERP turned ten brittle product screens into one extensible form. resolveRelationUsing handles the data side; a small SchemaRegistry handles the UI side. If you’re building a plugin-based Laravel or Filament app, it’s a pattern worth stealing.
Want to see how Aureus ERP fits your business? Tell us about your company. You can also read the Laravel docs on dynamic relationships or explore PR #1273 on GitHub.
