Skip to content

Architecture

One business runs several promotional-products storefronts from one codebase, one database and one admin panel. This shapes the whole design:

  • One catalog and one body of content, shared. Products, categories, pages and posts are not owned by a site. Each site chooses what it shows through content rules and can override individual fields, slugs and SEO.
  • The site comes from the request host. SiteResolver::forCurrentRequest() matches the host exactly against sites.domain. Nothing else in the URL says which site you are on.
  • Staff see everything. There is one staff login for all sites. What a member of staff may do depends on their role, not on a site.
  • Each site picks a theme (sites.theme), so several sites can look different on the same data.

When you change something, the question to ask is: does this hold up across N sites owned by one operator?

the root app: routes/web.php, AdminPanelProvider, config/roles.php, themes/
|
customer-panel
|
assistant sales forms
|
catalog
|
automations content-management
|
admin contacts locales file-library
|
custom-fields settings
|
utils countries placeholders simple-filament-layout

Modules only depend downward. The root app composes the modules and holds little logic of its own: the panel provider, the base layout component and the legacy import commands. The table below is the exact graph.

From each module’s composer.json, web-moves/* entries only:

Module Purpose Depends on
utils Shared concerns, the capability registry, the base plugin and policy classes (none)
countries Country, state and currency lookups, money helpers (none)
placeholders Placeholder support for merge text (none)
simple-filament-layout Structured form and infolist layouts (none)
locales Locales countries
contacts Addresses and contact details countries, utils
settings The Settings screen and its sections utils
file-library The staff file library utils
custom-fields Custom fields and field sets simple-filament-layout, utils
admin Staff users, roles, background tasks, activity log, Horizon custom-fields, settings, simple-filament-layout, utils
automations When / If / Then rules admin, placeholders, settings, utils
content-management Sites, projection, routing, pages, posts, menus, widgets, SEO, themes admin, contacts, countries, custom-fields, locales, placeholders, simple-filament-layout, utils
forms Site forms and their submissions content-management, utils
catalog Products, categories, pricing, suppliers and feeds, search admin, automations, contacts, content-management, countries, custom-fields, locales, settings, simple-filament-layout, utils
assistant Mister Smoothie, the admin AI assistant admin, catalog, content-management, custom-fields, utils
sales Customers, quotes, orders, invoices, purchase orders admin, automations, catalog, contacts, content-management, countries, custom-fields, placeholders, simple-filament-layout, utils
customer-panel The customers’ panel at /account contacts, content-management, sales, settings

tests/Unit/ModuleDependencyTest.php enforces this: a use WebMoves\Other\... in a module’s src/ must be declared in its composer.json, a package listed in the root composer.json must also be declared by each module that uses it, and the graph must have no cycles. So a lower module can’t reach upward. Where one needs something from a module above it, the higher module registers into a registry the lower one owns: Capabilities, SettingsSections, the automations registry, SiteResource::registerRecordPage().

There are three front doors, all in one Laravel application.

routes/web.php, wrapped in the SetActiveSiteTheme middleware:

  1. SetActiveSiteTheme resolves the site from the host and activates its theme, with the manifest’s parent as the view fallback.
  2. The specific routes match first: quote-cart/*, search, __preview/{document}.
  3. / goes to FrontPageController, and everything else to the catch-all /{routable}, which accepts letters, digits, -, _ and /.
  4. The routable binding (CmsServiceProvider::bindRoutableParameter()) finds an available site_documents row for (site, path). If there is none it looks in path_history for a 301 or a 410, and otherwise gives a 404. Listing pages paginate as /page/N.
  5. SiteDocumentController renders the view named after the source type’s handle (page.blade.php, product_category.blade.php), unless the entity’s own template file or its field set picks another. Views come from the active theme.

A 404 or 410 renders as the site’s own page in its theme (StorefrontErrorPage, wired in bootstrap/app.php).

Be careful adding top-level routes: the catch-all is broad, so anything new must be registered before it. See Routing.

/admin, defined in app/Providers/Filament/AdminPanelProvider.php. Uses the web guard and WebMoves\Admin\Models\User. User::canAccessPanel() requires a role. Each module contributes its resources and pages through a plugin. The panel does not depend on the host: the same panel answers on every domain, and no theme is active there.

/account, defined in app-modules/customer-panel/src/Providers/CustomerPanelProvider.php. A second Filament panel on the customer guard, with WebMoves\Sales\Models\Customer as its user. Customers sign in with an emailed one-time code. Colours, name, logo and favicon come from the site matching the host (PanelBranding), evaluated per request. See Customer panel.

The storefront doesn’t work out availability and URLs on each request. Every page, post, product and category is projected per site into a row of site_documents: whether it is available there, its resolved path, its SEO, and so on. Routing, listings and search read that table, indexed by site.

Observers rebuild rows when things are saved, through the Reindex facade (WebMoves\ContentManagement\Sites\Facades\Reindex). Direct updates that skip model events (a bulk update(), an import) need cms:reindex afterwards.

This is the design’s central idea: the projection is derived data, it can always be rebuilt, and public reads never have to re-derive rules. See Sites and the projection.

Service Used for
MariaDB All application data
Redis Queues, and cache and sessions in development and production
Horizon Runs the queue workers: Queues and scheduling
Meilisearch (through Scout) Storefront product search: Search
Reverb Websockets for the assistant’s chat
Headless Chromium Sales document PDFs