docs/unified-product-resource-plan.md
ProductResource is defined in the products plugin and extended separately by account, invoice,
inventories, sales, purchases, manufacturing, accounting. Inheritance branches:
Product (base)
├─ Account → Invoice → Sale (tax / accounting fields)
│ └ Purchase
└─ Inventory → Manufacturing (tracking / logistics fields)
Single-inheritance PHP cannot merge two branches, so no resource has all fields. Each plugin also registers its own ProductResource (own cluster, routes, nav) and injects fields by fragile positional array-index mutation of the parent's component tree. Result: inventory fields never appear on the sales product screen and vice-versa; users navigate between plugins to edit one product.
Every plugin keeps its own ProductResource (so Products nav stays in each plugin's cluster), but all of them render one unified form / table / infolist that includes every installed plugin's fields, columns, and relations.
ProductResource (products plugin) holds the registry (static arrays) + the core schema
built from named slots.ProductResource::contributeForm(...) at boot
writes to the single storage every subclass reads via static::. One registration → visible on all
product screens.form()/table()/infolist() overrides (inherits unified). Field defs move into contribute*()
calls in that plugin's ServiceProvider boot(), guarded implicitly by the plugin being installed.ProductResource::getUrl()/::class refs stay valid.trait HasProductContributions
{
protected static array $formContrib = []; // slot => [ [priority, Closure], ... ]
protected static array $tableContrib = [];
protected static array $infolistContrib = [];
protected static array $eagerLoad = [];
public static function contributeForm(string $slot, Closure $factory, int $priority = 0): void;
public static function contributeTable(string $slot, Closure $factory, int $priority = 0): void;
public static function contributeInfolist(string $slot, Closure $factory, int $priority = 0): void;
public static function contributeEagerLoad(array $relations): void;
protected static function slot(array $bag, string $slot, mixed ...$args): array; // priority-sorted, flattened
}
| Slot | Replaces | Used by |
|---|---|---|
left.general.after | array_splice($left,1,0,$policy) | accounts (invoice_policy, account props) |
left.inventory (replace-or-default) | $left[2] = richInventorySection | inventories |
left.append | $left[] = customFields | inventories, custom fields |
right.pricing.fields | merge taxes into pricing | accounts |
right.append | — | future |
form.hidden | Hidden::make('uom_id'), sale_line_warn | accounts |
table.columns | append columns | inventories |
table.filters.reject | reject responsible constraint | invoices/sales/purchases |
table.filters.append / table.actions / table.bulkActions | — | various |
infolist.* | mirror of form slots | inventories, accounts |
Base renders core sections, then folds each slot's contributions at the marked position.
left.inventory = "replace if any contribution exists, else default" (preserves base GOODS section
when inventories absent).
Resource standardizes on base Product. Plugins inject at boot:
Product::contributeFillable([...]);
Product::contributeCasts([...]);
Product::resolveRelationUsing('routes', fn (Product $p) => $p->belongsToMany(Route::class, ...));
contributeFillable/Casts merged in the model constructor (registry filled at boot).resolveRelationUsing() — covers form Select->relationship()
(query + sync), dot-columns, eager loading, and relation-manager pages ($ownerRecord->{rel}()).resolveRelationUsing() per relation, co-located with that plugin's field contribution,
referencing the related class inside the closure (only runs when the plugin is loaded).method_exists() returns false. A few
Filament internals (and ParentResourceRegistration) probe with method_exists. For those specific
relations, fall back to a real method via a plugin trait on the model the page targets.contributeEagerLoad() slot so contributed columns don't N+1.Product. No behavior change.form/table/infolist into core-builders + slot folds. Visual checkpoint — base unchanged.table.filters.reject contribution; shell only.Product model.Done + verified (lint, boot, tinker):
ProductResource delegates to Schemas/ProductForm, Schemas/ProductInfolist,
Tables/ProductsTable; registry at ProductResource/Support/ProductSchemaRegistry. Base Product
uses Models/Concerns/HasProductAttributes (contributed fillable/casts).left.inventory slot; fillable/casts +
routes/responsible relations registered on base Product in packageBooted. Resource is now a
thin shell (nav/pages/model only).right.pricing.fields, invoice-policy + account
properties → left.general.after, sale_line_warn → hidden; fillable + productTaxes/
supplierTaxes/propertyAccountIncome/propertyAccountExpense relations on base Product. Form
override stripped.table() overrides (drop the
responsible filter) are intentionally per-resource and kept. Purchases inherits the unified form
for free (extends Account).Verified: Sale\Product, Inventory\Product, Purchase\Product all inherit the inventory + account
fillable/casts/relations → every product screen renders & saves both field sets.
Filament binds relation pages to one resource, so sub-nav/pages can't be shared like static schema.
Chosen: each product resource registers the union of pages as its own thin subclasses, guarded by
Package::isPluginInstalled. Header actions unified via a registry actions('header', ...) slot that
the base View/Edit pages fold in.
actions() / renderActions().ViewProduct/EditProduct prepend ProductSchemaRegistry::renderActions('header', $this).UpdateQuantityAction to header; its own View/Edit header-action overrides
removed (inventory EditProduct::beforeSave kept). moveLines/moves/quantities relations
resolved on base Product.ProductResource now exposes ManageQuantities + ManageMoves (thin subclasses of the
inventory pages, $resource rebound) in getPages() + getRecordSubNavigation(), guarded by
inventories install. Attributes/Variants already shared (base relations).UpdateQuantityAction; sales product routes include moves/quantities.The registry was generalized so any resource (Product, Partner, …) can reuse it:
Webkul\Support\Filament\Contributions\SchemaRegistry — generic store keyed by a scope string
(form/infolist/table/actions slots + eager-loads), priority-sorted slot resolution.Webkul\Support\Filament\Contributions\AbstractSchemaRegistry — scope-bound facade; subclass it and
implement scope() to get a terse typed API (form()/infolist()/table()/actions()/render*()/has*Slot()).Webkul\Support\Models\Concerns\HasContributedAttributes — reusable model trait (contributeFillable
/ contributeCasts, merged via initialize*); each using-class gets isolated storage.ProductSchemaRegistry is now just extends AbstractSchemaRegistry { scope() => 'product' } — all
existing call sites unchanged. Base Product uses the support trait.To unify a new resource (e.g. PartnerResource): create a PartnerSchemaRegistry extends AbstractSchemaRegistry (scope 'partner'), split its base resource into Schemas/PartnerForm etc.
that fold registry slots, use HasContributedAttributes on the base Partner model, and have each
plugin contribute in packageBooted + register relation-page subclasses per resource (approach B).
HasCustomFields "Additional" section) was previously appended only on the
inventory product form. The inventory resource no longer overrides the form, so that section is
temporarily gone. Next: add a single left.append custom-fields contribution keyed to the base
ProductResource (one shared section, not per-plugin).responsible-filter rejects to a per-resource hook; clean the
purchases no-op form override.Reuses the same support-package registry (scope = 'partner').
Webkul\Partner\...\PartnerResource split into
PartnerResource/Schemas/PartnerForm, Schemas/PartnerInfolist, Tables/PartnersTable, with a
Support/PartnerSchemaRegistry extends AbstractSchemaRegistry. Slots: form/infolist
general.after, sales.fields (inside Sales fieldset), salesPurchase.append (inside the
Sales/Purchase tab), tabs.append (whole tabs); table columns/filters.append/filters.reject/ actions/bulkActions/groups; actions header. Base Partner model uses HasContributedAttributes.accounts/.../PartnerResource/Schemas/AccountPartnerSchema and registered in AccountServiceProvider:: contributePartnerSchema() (install-guarded); account form/infolist overrides stripped. Account
fillable + 7 property* relations contributed to base Partner. Verified: Contact\Partner
(extends base, not accounts) now inherits the account fillable/relations → the invoicing & sales
tabs render and save on the Contacts/Website partner screens (the main gap), and on every
customer/vendor screen across invoices/accounting/sales/purchases.Remaining (Phase 3 — sub-nav/actions, approach B): propagate relation tabs uniformly — Bank
Accounts (accounts-installed) onto Contacts/Website; Bills + Purchases (purchase-only, relations on
Purchase\Partner) onto the other partner screens if desired — via thin per-resource page subclasses
Partner::resolveRelationUsing for accountMoves/orders, exactly like the product Moves/Quantities/
Vendors propagation. Header actions are already shared (defined only on base partner pages).Create/edit/list a product with combos: products-only; +inventories; +sales; all installed.
Verify: all fields present & saved to products_products; relation pages visible only when their plugin
is on; deep links from Orders/Quotations (openProduct) resolve; shield permissions intact.