The Pollora framework utilizes the concept of modules to organize coherent sets of functionalities, thus enhancing maintainability, scalability, and clear separation of responsibilities. This modular management relies on the Laravel Modules package developed by Nwidart.
In Pollora, a module organizes functionalities specific to a particular project, which are usually hard to reuse elsewhere. This contrasts with a WordPress plugin, which is generally designed to be generic and reusable across multiple projects. Thus, modules provide an optimal solution to encapsulate specific business logic while fully leveraging the Laravel ecosystem.
All functionalities available in the main application directory (app/), such as managing post types, taxonomies, WordPress hooks, etc., are fully accessible within modules. Additionally, Pollora provides automatic discovery of structures within modules, eliminating the need for manual registration of service providers, post types, taxonomies, and other Laravel/WordPress components.
A module in Pollora groups autonomous functionalities that can be enabled or disabled on demand. Each module has its own file structure, allowing clear management of associated resources, routes, views, configurations, migrations, models, and tests.
php artisan pollora:make:module PortfolioThe command downloads the module-default template, fills in its name, enables the module, runs composer dump-autoload (the module's composer.json is merged into the project's) and builds its assets with npm. The default module holds what a Pollora module uses, and nothing it would have to delete:
Modules/Portfolio/
├── app/
│ └── Cms/Hooks/PortfolioHooks.php # an #[Action] example, discovered without a provider
├── resources/
│ ├── assets/app.js, app.css # Tailwind CSS v4, without its preflight
│ └── views/blocks/ # Gutenberg blocks, registered by Pollora
├── composer.json # PSR-4 Modules\Portfolio\ → app/
├── module.json # "providers": [] — discovery does the registering
├── package.json
└── vite.config.js # @pollora/vite-config, type "module"
There is no service provider, controller, route file, configuration, seeder or test folder by default: classes declared with attributes in app/ are discovered. Each Laravel layer is one flag away:
| Flag | Adds |
|---|---|
--provider |
app/Providers/PortfolioServiceProvider.php, listed in module.json |
--routes |
routes/web.php and the RouteServiceProvider that loads it (implies --provider) |
--api |
routes/api.php, under /api (implies --routes) |
--config |
config/config.php, read as config('portfolio.*') (implies --provider) |
--database |
database/migrations, seeders and factories, with their PSR-4 entries |
--tests |
tests/Feature, tests/Unit and a Pest test |
--full |
every layer above |
--no-assets |
no package.json, vite.config.js or resources/assets, for a PHP-only module |
Other options: --description, --author, --no-enable, --no-npm, --repository=owner/repo and --repo-version=tag for another template, --offline for the copy bundled with the framework (also used when GitHub cannot be reached), --force to replace an existing module.
A layer added later uses nwidart's own generators: php artisan module:make-provider PortfolioServiceProvider Portfolio, module:make-migration, and so on.
nwidart's php artisan module:make Portfolio writes the same lean module, offline, from the copy bundled with the framework. A project whose config/modules.php sets nwidart's paths or stubs (its own published configuration) keeps nwidart's stock module instead.
Pollora's generators take --module, and write where nwidart found the module, under the namespace its composer.json maps onto app/ — a module may use Module\Portfolio\ rather than Modules\Portfolio\:
php artisan pollora:make:post-type Project --module=Portfolio
php artisan pollora:make:block project-card --module=PortfolioA block lands in resources/views/blocks/project-card, named portfolio/project-card.
A module builds like a theme or a plugin, with the same Vite, Tailwind CSS v4 and block tooling, through @pollora/vite-config:
// Modules/Portfolio/vite.config.js
import { defineConfig } from 'vite';
import pollora from '@pollora/vite-config';
export default defineConfig({
plugins: [pollora({ type: 'module', name: 'portfolio' })],
});| Build | public/build/module/<kebab-name>, with its manifest.json |
| Hot file | public/<kebab-name>.hot |
| Dev server | DDEV-aware, port 5175 (VITE_PORT to change it) |
| Entries | resources/assets/app.js, and every block in resources/views/blocks |
cd Modules/Portfolio
npm install
npm run dev # hot reload
npm run buildEvery enabled module with a vite.config.js gets the module.<kebab-name> asset container:
use Pollora\Support\Facades\Asset;
Asset::add('portfolio/app', 'app.js')
->container('module.portfolio')
->toFrontend()
->useVite();nwidart's stock vite.config.js builds into public/build-<lower>, where Pollora never looks; pollora:doctor says so. Move the module onto the template's build:
php artisan pollora:module:frontend PortfolioIt writes package.json, vite.config.js and resources/assets/app.{js,css}, keeping a .bak of each file it replaces (--no-backup to skip them). Entries other than app.js go in the input option of pollora(). Nothing runs at upgrade: a project's modules are its own code.
Each module can have its own dependencies defined in composer.json, but actual installation is managed centrally at the root of the main application by merging dependency files:
"extra": {
"merge-plugin": {
"include": [
"Modules/*/composer.json"
],
"merge-dev": false
}
}This approach ensures coherent, centralized package management while maintaining modular flexibility.
A module's require is merged; its require-dev is not (merge-dev: false). A module installed with Composer is merged too, and its development tools (PHPUnit, Rector…) would otherwise become requirements of the project: the next composer install then removed WordPress core from public/cms. Development dependencies belong in the project's own require-dev. The skeleton sets it since v13.34.1; an older project adds it to extra.merge-plugin.
A module published as a Composer package installs with composer require, straight into Modules/, where nwidart finds it:
composer require pollora/meilifacetsThe skeleton routes it there with one installer-paths rule, handled by composer/installers (already used for WordPress plugins and themes):
"extra": {
"installer-paths": {
"public/content/mu-plugins/{$name}/": ["type:wordpress-muplugin"],
"public/content/plugins/{$name}/": ["type:wordpress-plugin"],
"public/content/themes/{$name}/": ["type:wordpress-theme"],
"Modules/{$name}/": ["vendor:pollora"]
}
}- The rule must come last.
composer/installersapplies the first rule that matches, and avendor:rule ignores the package type: placed first, it would also send Pollora's WordPress plugins (pollora/mcp-connector,pollora/ai-visibility) toModules/. Last, it only catches what the rules above did not, which are thepollora/*modules of typelaravel-library; Pollora's other packages (library,project) are not handled bycomposer/installersand stay invendor/. - A module from another vendor needs its own line, for instance
"Modules/{$name}/": ["vendor:pollora", "vendor:acme"]. The rule does not targettype:laravel-libraryalone: dozens of ordinary Laravel packages declare that type and would land inModules/. - The skeleton ships this rule since v13.34.1. A project created from v13.34.0 or earlier adds it to its
composer.json, last, and"merge-dev": falsetoextra.merge-plugin(see Dependency Management).
The module's composer.json declares the type and the folder name:
{
"name": "pollora/meilifacets",
"type": "laravel-library",
"require": {
"composer/installers": "^2.3"
},
"extra": {
"installer-name": "MeiliFacets"
}
}installer-name sets the folder, Modules/MeiliFacets/, with the exact case nwidart expects; the package name itself has no naming constraint.
An installed module is not enabled yet: php artisan module:enable MeiliFacets, or Plugins › Modules (below).
composer remove pollora/meilifacets deletes Modules/MeiliFacets/; remove its state too (pollora:doctor lists states left for modules no longer on disk).
A module installed with Composer carries its package's version, matched by install path; a local module has none and is never checked. Pollora reads the latest release from where the project's composer.json gets the package: a composer repository (Private Packagist, Satis), a GitHub vcs repository (MODULES_GITHUB_TOKEN for a private one), or Packagist. It checks once a day from WP-Cron and caches the answer for 12 hours — never during a front-end request:
php artisan pollora:module:outdated # checks now
php artisan pollora:module:outdated --jsonAn update shows in Plugins › Modules ("1.3.0 · 1.4.0 available", with the composer update command), in Site Health ("Pollora modules are up to date") and in pollora:status. Code still arrives through Composer: there is no one-click update. A dev-* version is never reported outdated.
php artisan module:enable Portfolio
php artisan module:disable PortfolioModule providers register during Laravel's register phase, before anything can switch them: a change applies from the next request.
The Plugins screen has a Modules (n) view next to All, Active and Must-Use: every module, its description and path, its state, its version and where its state is stored, with Enable / Disable on each row and as bulk actions. Switching needs the activate_plugins capability (modules.admin.capability). WordPress's own plugin rows are untouched: a module has no plugin file for WordPress to load.
- A locked module has no switch, and the row says why (see below).
MODULES_ADMIN_TOGGLE=falseturns every switch off; they are on by default, production included.- With the JSON file, the view warns that the next deployment resets a change, and each switch asks first. When the file cannot be written (a read-only release directory), switches are off and the view says how to change states.
Tools › Pollora and pollora:status also show where module states are stored.
Whether a module is enabled comes from a connector:
| Connector | Reads and writes | Survives a deployment | For |
|---|---|---|---|
json (default) |
modules_statuses.json, nwidart's file |
Only if committed | Local work, simple deploys |
database |
the pollora_modules WordPress option (JSON, not autoloaded) |
Yes | Sites switched from the admin |
config |
connectors.config.states, or MODULES_ENABLED / MODULES_DISABLED |
Yes, it ships with the code | Immutable deploys, containers |
nwidart asks which modules are enabled while it registers, before any provider of the application and before WordPress loads. The connector is therefore chosen in a published config/modules.php, never from a provider:
php artisan vendor:publish --tag=pollora-modules// config/modules.php — use Pollora\Modules\Infrastructure\Activation\ModuleConnectors;
'activator' => 'pollora',
'connector' => env('MODULES_CONNECTOR', 'json'),
'connectors' => [
'json' => ['path' => base_path('modules_statuses.json')],
'database' => ['option' => 'pollora_modules', 'fallback' => 'json'],
'config' => [
'states' => [],
'enabled' => ModuleConnectors::names(env('MODULES_ENABLED', '')),
'disabled' => ModuleConnectors::names(env('MODULES_DISABLED', '')),
],
],
'locked' => [
'enabled' => ModuleConnectors::names(env('MODULES_LOCKED_ENABLED', '')),
'disabled' => ModuleConnectors::names(env('MODULES_LOCKED_DISABLED', '')),
],Without this file, nwidart's own activator keeps reading modules_statuses.json, and MODULES_CONNECTOR or MODULES_LOCKED_* are ignored (pollora:doctor warns).
The database connector reads the option with Laravel's connection, which shares WordPress's database and table prefix, and writes it with update_option() once WordPress is loaded. While the options table cannot be read (a fresh install), it reads its fallback and pollora:doctor says so. Switch connector after copying the current states into the new one:
php artisan pollora:module:connector # where the states live now
php artisan pollora:module:connector database --import # copy them, then set MODULES_CONNECTOR=databaseLocked modules. locked.enabled and locked.disabled force a state over any connector; switching a locked module from the console throws ModuleLockedException, and the admin shows no switch for it.
Your own connector implements Pollora\Modules\Domain\Contracts\ModuleStateConnector (all, set, forget, writable, persistent, label) and is declared by class, or registered in bootstrap/app.php:
'connectors' => [
'redis' => ['class' => App\Modules\RedisStateConnector::class],
],// bootstrap/app.php, before ->create()
use Pollora\Modules\Infrastructure\Activation\ModuleConnectors;
ModuleConnectors::extend('redis', fn ($app, array $config) => new RedisStateConnector($app['redis']));A switch clears nwidart's provider manifest and the discovery cache, and fires Pollora\Modules\Domain\Events\ModuleEnabled or ModuleDisabled (module, source admin or console, WordPress user). A configuration or route cache written before the switch still holds the old state: php artisan optimize:clear (pollora:doctor warns). Multisite sites share one state for the whole network.
Pollora discovers the classes of every enabled module in its app/ directory, with no registration:
- Post types and taxonomies: classes with
#[PostType]and#[Taxonomy] - WordPress hooks: methods with
#[Action]and#[Filter] - REST routes:
#[WpRestRoute] - Scheduled tasks:
#[Schedule] - Gutenberg blocks: folders in
resources/views/blocks
// Modules/Portfolio/app/Cms/PostTypes/Project.php
namespace Modules\Portfolio\Cms\PostTypes;
use Pollora\Attributes\PostType;
use Pollora\Attributes\PostType\Supports;
#[PostType('project')]
#[Supports(['title', 'editor', 'thumbnail'])]
class Project {}After adding such a class with the discovery cache on, run php artisan discovery:clear.
You can also trigger discovery manually using helper functions:
// Discover all structures in a module
pollora_discover_module('/path/to/module');
// Discover structures in any path
pollora_discover_all_in_path('/custom/path');Or use the discovery service directly:
use Pollora\Modules\Domain\Contracts\ModuleDiscoveryOrchestratorInterface;
$discovery = app(ModuleDiscoveryOrchestratorInterface::class);
$discovery->discover('/path/to/module');- Keep each module focused on a single responsibility (Single Responsibility Principle).
- Use modules to clearly separate business contexts (Domain-Driven Design).
- Prefer using module-specific namespaces to avoid conflicts.
- Test a module in its own
testsdirectory (pollora:make:module --tests). - Leverage automatic discovery: Use PHP 8 attributes instead of manual registrations for cleaner, more maintainable code.
- Organize by feature: Group related service providers, models, and controllers within logical subdirectories.
- Follow naming conventions: Use descriptive class names that clearly indicate their purpose and functionality.
pollora:doctor (and Tools › Site Health) checks modules too: an unbuilt module or one built where Pollora does not look, a stock nwidart Vite config, a connector reading its fallback, a state for a module no longer on disk, a state file that cannot be written while the admin is the way to switch, caches older than the last switch, and MODULES_* settings ignored without config/modules.php.
To further explore the advanced features of the Laravel Modules package, refer to: