This is the full developer documentation for Pollora # Why Pollora > Why Pollora runs WordPress inside a Laravel application: the problem with procedural hooks and globals, what you keep from WordPress, and the trade-offs. WordPress is a very good CMS. Editors know the admin, the plugin ecosystem covers almost any need, and every host runs it. What it is not is a modern PHP application framework, and teams that write a lot of custom code for WordPress feel that gap every day. Pollora exists to close it without giving up what makes WordPress useful. ## The problem [Section titled “The problem”](#the-problem) Custom WordPress code is mostly procedural. Behaviour is attached with `add_action()` and `add_filter()` calls scattered across `functions.php` and plugin files, usually as global functions or closures. Post types are arrays of options passed to `register_post_type()` inside an `init` callback. The front end is a set of template files picked by the template hierarchy, each mixing queries, logic and HTML. There is no service container, no routing layer you control, no standard way to test, queue or schedule work. Modern PHP has answers for all of this, mostly in Laravel: dependency injection, routing and middleware, Blade, Eloquent, Artisan, queues, a test setup. The question is how to get them without fighting WordPress. ## The approach [Section titled “The approach”](#the-approach) A Pollora project is a Laravel application. Laravel boots first, then loads WordPress inside it, so WordPress keeps doing its job (admin, database, plugins, REST API) while Laravel handles the front end. **Attributes instead of registration code.** Hooks, post types, taxonomies, REST routes, scheduled tasks and AJAX handlers are declared with PHP 8 attributes on ordinary classes. Pollora’s [auto-discovery](/core-concepts/auto-discovery/) finds them and registers them, with no list to maintain. A post type, before: functions.php ```php add_action('init', function () { register_post_type('book', [ 'labels' => [ 'name' => __('Books'), 'singular_name' => __('Book'), // ...a dozen more labels ], 'publicly_queryable' => true, 'has_archive' => true, 'supports' => ['title', 'editor', 'thumbnail'], 'menu_icon' => 'dashicons-book', ]); }); ``` And with Pollora, where the slug and every label are generated from the class name (see [Post Types](/content/post-types/)): app/Cms/PostTypes/Book.php ```php namespace App\Cms\PostTypes; use Pollora\Attributes\PostType; use Pollora\Attributes\PostType\HasArchive; use Pollora\Attributes\PostType\MenuIcon; use Pollora\Attributes\PostType\PubliclyQueryable; use Pollora\Attributes\PostType\Supports; #[PostType] #[PubliclyQueryable] #[HasArchive] #[Supports(['title', 'editor', 'thumbnail'])] #[MenuIcon('dashicons-book')] class Book { } ``` Hooks follow the same pattern. Before: functions.php ```php add_action('init', 'mytheme_setup', 20); function mytheme_setup() { // ... } add_filter('the_content', function ($content) { return str_replace('ugly', 'shiny', $content); }); ``` After, in a class that is discovered automatically (see [Actions & Filters](/hooks/actions-filters/)): app/Cms/Hooks/ContentHooks.php ```php namespace App\Cms\Hooks; use Pollora\Attributes\Action; use Pollora\Attributes\Filter; class ContentHooks { #[Action('init', priority: 20)] public function setup(): void { // ... } #[Filter('the_content')] public function polish(string $content): string { return str_replace('ugly', 'shiny', $content); } } ``` **Routing you control.** Routes in `routes/web.php` are matched first. `Route::wp('single', ...)` matches a WordPress conditional tag and sends it to a closure or controller, with middleware and named routes like any Laravel route. Anything not routed falls back to the template hierarchy, rendered with Blade views (see [WordPress Routes](/routing/wordpress-routes/)). **The rest of Laravel.** Blade themes built with Vite and Tailwind CSS, Eloquent (including models for WordPress posts, terms and users), Artisan, queued listeners on WordPress events, the scheduler, a WordPress authentication guard, and the skeleton’s PHPUnit setup. Gutenberg blocks are written in JSX, built with Vite and rendered with Blade. ## What you keep from WordPress [Section titled “What you keep from WordPress”](#what-you-keep-from-wordpress) * **The admin and the editors’ workflow.** Editors log in to `wp-admin` and use the block editor as usual. * **Plugins.** WordPress is loaded normally, so plugins run as they do in WordPress. They are installed with Composer, and Pollora even maps WooCommerce conditional tags to `Route::wp()` aliases. * **The data.** Content lives in the standard WordPress tables. * **Ordinary PHP hosting.** Any server that runs PHP 8.4 and MySQL or MariaDB, with the document root pointed at `public/` (see [Server Configuration](/getting-started/server-configuration/)). ## Trade-offs, and who it is not for [Section titled “Trade-offs, and who it is not for”](#trade-offs-and-who-it-is-not-for) **If you only need a theme**, Pollora is more than you need. A starter theme like Sage on a standard install is simpler (see [the comparison](/compare/)). **It is a new project layout, not a plugin.** WordPress core lives in `public/cms`, `wp-content` in `public/content`, and the web server must serve `public/`. Hosting that does not let you choose the document root or run Composer is a poor fit. By default Pollora also disables installing plugins and themes from the admin (`DISALLOW_FILE_MODS`) and WordPress core auto-updates: changes go through Composer and deployment. **Two frameworks to keep updated.** This is the most common concern, and Pollora’s versioning is designed around it. Version numbers follow the Laravel release Pollora is built on: 13.34 means Laravel 13.34. The skeleton and the framework are tagged together, and a skeleton tag pins the framework tag of the same number, so one version number describes an install. WordPress core is a Composer dependency like the rest. For major upgrades, [Nectar](/nectar/overview/) ships upgrade prompts that walk AI coding agents through the steps. **A smaller community.** Pollora is maintained by AmphiBee and its community is much smaller than that of the Roots projects. The current release, v13.35.2, is stable, but you will find fewer third-party tutorials. If those trade-offs work for you, [install Pollora](/getting-started/installation/) and try it, or read the [FAQ](/faq/). # Configuration > Configure a new Pollora project: environment variables, database, WordPress settings, local environments such as DDEV, and fixes for common setup issues. * [Initial Configuration](#initial-configuration) * [Environment Based Configuration](#environment-based-configuration) * [Databases & Migrations](#databases-and-migrations) * [Directory Configuration](#directory-configuration) * [WordPress Configuration](#wordpress-configuration) * [Publishing the Configuration File](#publishing-the-configuration-file) * [Customizing Route Conditions](#customizing-route-conditions) * [WordPress Authentication Keys](#wordpress-authentication-keys) * [Multisite Configuration](#multisite-configuration) * [Database Caching](#database-caching) * [WordPress Constants](#wordpress-constants) * [Environment Variables](#environment-variables) * [Development Environments](#development-environments) * [Troubleshooting](#troubleshooting) * [Next Steps](#next-steps) []() ## Initial Configuration [Section titled “Initial Configuration”](#initial-configuration) All configuration files for Pollora are located in the `config` directory. Feel free to familiarize yourself with the options as each one is well-documented. Out of the box, Pollora requires minimal configuration. However, it might be worthwhile to review the `config/app.php` file and its accompanying documentation to tailor settings like `timezone` and `locale` to your needs. []() ### Environment Based Configuration [Section titled “Environment Based Configuration”](#environment-based-configuration) Configuration values in Pollora can vary depending on the environment (local vs. production). These values are usually defined in the `.env` file at your application’s root. For security reasons, never commit your `.env` file to source control. Different developers or servers might need different configurations, and exposing sensitive credentials would pose a significant risk. > **Note**\ > For a deep dive into the `.env` file and environment configurations, peruse the full [configuration documentation](https://laravel.com/docs/13.x/configuration#environment-configuration). []() ### Databases & Migrations [Section titled “Databases & Migrations”](#databases--migrations) With your Pollora application ready, you might want to store data. The application’s `.env` configuration points Pollora at a MySQL database, and that is a requirement: WordPress is only loaded when Laravel’s default connection uses the `mysql` driver, which covers MySQL 5.7+ and MariaDB 10.3+. SQLite and PostgreSQL are not supported. If you’re on macOS, installing MySQL or MariaDB is a breeze with [DBngin](https://dbngin.com/). Set the connection in `.env` with Laravel’s names (`DB_CONNECTION=mysql`, `DB_HOST`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`). Names from a Bedrock or `wp-config.php` setup, such as `DB_NAME`, are not read; `php artisan pollora:doctor` flags them. Finally, run your application’s [database migrations](https://laravel.com/docs/13.x/migrations) to establish your database tables: ```shell php artisan migrate ``` []() ### Directory Configuration [Section titled “Directory Configuration”](#directory-configuration) Always serve Pollora from the root of the “web directory” set for your server. Avoid serving Pollora from a subdirectory as it could inadvertently expose sensitive files. []() ## WordPress Configuration [Section titled “WordPress Configuration”](#wordpress-configuration) Pollora uses a `wordpress.php` configuration file that contains several important settings: 1. **WordPress Route Conditions**: Mappings between WordPress conditional tags and route URIs 2. **WordPress Authentication Keys**: Keys and salts for WordPress security 3. **Multisite Configuration**: Settings for multisite installations 4. **Database Caching**: Options for database caching 5. **WordPress Constants**: Define WordPress behavior through constants []() ### Publishing the Configuration File [Section titled “Publishing the Configuration File”](#publishing-the-configuration-file) To customize these settings, you can publish the configuration file to your application: ```bash php artisan vendor:publish --tag=wordpress ``` This command will copy the framework’s configuration file to your application’s `config/` directory, allowing you to customize it according to your needs. []() ### Customizing Route Conditions [Section titled “Customizing Route Conditions”](#customizing-route-conditions) WordPress route conditions are particularly useful for defining routes that match WordPress conditional functions. You can add your own conditions or replace existing ones: config/wordpress.php ```php return [ 'conditions' => [ // Add your custom conditions 'is_custom_post_type' => 'custom-post-type', // Override existing conditions 'is_page' => ['page', 'static-page'], ], // ... other configuration options ]; ``` For more information on using route conditions, see the [Routing](/routing/wordpress-routes/#wordpress-route-conditions) documentation. []() ### WordPress Authentication Keys [Section titled “WordPress Authentication Keys”](#wordpress-authentication-keys) The configuration file also contains WordPress authentication keys and salts, which are essential for your application’s security: config/wordpress.php ```php return [ // ... other options // WordPress authentication keys and salts 'auth_key' => env('AUTH_KEY'), 'secure_auth_key' => env('SECURE_AUTH_KEY'), 'logged_in_key' => env('LOGGED_IN_KEY'), 'nonce_key' => env('NONCE_KEY'), 'auth_salt' => env('AUTH_SALT'), 'secure_auth_salt' => env('SECURE_AUTH_SALT'), 'logged_in_salt' => env('LOGGED_IN_SALT'), 'nonce_salt' => env('NONCE_SALT'), ]; ``` These values are typically defined in your `.env` file during installation. If you need to generate new keys, you can use the [WordPress Secret Key Generator](https://api.wordpress.org/secret-key/1.1/salt/). []() ### Multisite Configuration [Section titled “Multisite Configuration”](#multisite-configuration) WordPress multisite is configured with WordPress constants. Pollora defines every entry of the `constants` array of `config/wordpress.php` as a constant (keys upper-cased), so publish the file (`php artisan vendor:publish --tag=wordpress`) and add the multisite entries to that array — the published file does not contain them: config/wordpress.php ```php 'constants' => [ // ... the authentication keys and salts already there // WordPress multisite configuration 'wp_allow_multisite' => env('WP_ALLOW_MULTISITE'), 'multisite' => env('MULTISITE'), 'subdomain_install' => env('SUBDOMAIN_INSTALL'), 'domain_current_site' => env('DOMAIN_CURRENT_SITE'), 'path_current_site' => env('PATH_CURRENT_SITE'), 'site_id_current_site' => env('SITE_ID_CURRENT_SITE'), 'blog_id_current_site' => env('BLOG_ID_CURRENT_SITE'), ], ``` Then define these variables in your `.env` file. Keys placed at the top level of `config/wordpress.php`, outside `constants`, are not read. []() ### Database Caching [Section titled “Database Caching”](#database-caching) Pollora also supports caching WordPress database queries: config/wordpress.php ```php return [ // ... other options // Database caching 'caching' => env('DB_CACHE'), ]; ``` Enable this option by setting `DB_CACHE=true` in your `.env` file to improve your application’s performance. []() ### WordPress Constants [Section titled “WordPress Constants”](#wordpress-constants) You can define additional WordPress constants in your configuration file. These constants control various aspects of WordPress behavior: config/wordpress.php ```php return [ // ... other options // WordPress constants 'constants' => [ 'WP_AUTO_UPDATE_CORE' => false, 'DISALLOW_FILE_MODS' => true, 'DISALLOW_FILE_EDIT' => true, 'DISABLE_WP_CRON' => true, 'WP_POST_REVISIONS' => 5, // Add your custom constants here ], ]; ``` By default, Pollora sets several constants for security and performance: * `WP_AUTO_UPDATE_CORE`: Disables WordPress core auto-updates * `DISALLOW_FILE_MODS`: Prevents plugin and theme installations from the admin * `DISALLOW_FILE_EDIT`: Disables the built-in file editor * `DISABLE_WP_CRON`: Disables the WordPress cron system (use Laravel’s scheduler instead) * `WP_POST_REVISIONS`: Limits the number of post revisions stored You can override these defaults or add your own constants in your application’s configuration file. []() ## Environment Variables [Section titled “Environment Variables”](#environment-variables) The installation process will create a `.env` file with your configuration. Key variables include: ```env # Application settings APP_URL=your-site-url APP_ENV=local APP_DEBUG=true # Database settings DB_CONNECTION=mysql DB_HOST=your-database-host DB_PORT=3306 DB_DATABASE=your-database-name DB_USERNAME=your-database-user DB_PASSWORD=your-database-password # WordPress authentication keys and salts AUTH_KEY=your-auth-key SECURE_AUTH_KEY=your-secure-auth-key LOGGED_IN_KEY=your-logged-in-key NONCE_KEY=your-nonce-key AUTH_SALT=your-auth-salt SECURE_AUTH_SALT=your-secure-auth-salt LOGGED_IN_SALT=your-logged-in-salt NONCE_SALT=your-nonce-salt # WordPress multisite configuration (if needed) # WP_ALLOW_MULTISITE=true # MULTISITE=true # SUBDOMAIN_INSTALL=false # DOMAIN_CURRENT_SITE=example.com # PATH_CURRENT_SITE=/ # SITE_ID_CURRENT_SITE=1 # BLOG_ID_CURRENT_SITE=1 # WordPress database caching DB_CACHE=false ``` During installation, the WordPress authentication keys and salts are automatically generated for security. You can regenerate these keys at any time using the [WordPress Secret Key Generator](https://api.wordpress.org/secret-key/1.1/salt/). []() ## Development Environments [Section titled “Development Environments”](#development-environments) Pollora automatically detects and configures itself for common development environments: ### DDEV [Section titled “DDEV”](#ddev) When using DDEV, the system will automatically: * Detect DDEV configuration * Use appropriate database settings * Set the correct site URL ### Laradock [Section titled “Laradock”](#laradock) With Laradock, the system will: * Use Laradock’s database configuration * Configure appropriate host settings []() ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Database Connection Issues [Section titled “Database Connection Issues”](#database-connection-issues) If you encounter database connection issues: 1. Verify your database credentials 2. Ensure your database server is running 3. Check if the database exists and is accessible 4. Run `php artisan pollora:env:setup` to reconfigure database settings ### Installation Failed [Section titled “Installation Failed”](#installation-failed) If the WordPress installation fails: 1. Check the error message 2. Verify database permissions 3. Ensure all required PHP extensions are installed 4. Run `php artisan pollora:install` to retry the installation []() ## Next Steps [Section titled “Next Steps”](#next-steps) With your Pollora project configured, here are the recommended next steps: * [Routing](/routing/wordpress-routes/) — Learn WordPress routing with `Route::wp()` * [Theming](/theming/theme-structure/) — Create your first theme with Blade templates * [Post Types](/content/post-types/) — Register custom post types with PHP attributes For Laravel-specific concepts, see the [Laravel documentation](https://laravel.com/docs/13.x). # Environment Management > Detect and manage environments in Pollora, from local to staging and production, with one API that works in both WordPress and Laravel contexts. The Pollora framework provides a comprehensive environment management system that works seamlessly in both WordPress and Laravel contexts. This system follows hexagonal architecture principles to ensure clean separation of concerns and easy testability. ## Overview [Section titled “Overview”](#overview) The environment management system consists of: * **Environment Detection**: Automatic detection of the current environment (development, production, staging, etc.) * **Context-Aware Implementation**: Different detection strategies for WordPress and Laravel environments * **Service Layer**: High-level service for environment-based decision making * **Performance Optimization**: Environment-based caching strategies ## Architecture [Section titled “Architecture”](#architecture) ### Domain Layer [Section titled “Domain Layer”](#domain-layer) **EnvironmentDetectorInterface** (`src/Application/Domain/Contracts/EnvironmentDetectorInterface.php`) The core contract defining environment detection capabilities: ```php interface EnvironmentDetectorInterface { public function getEnvironment(): string; public function isProduction(): bool; public function isDevelopment(): bool; public function isStaging(): bool; public function isEnvironment(string $environment): bool; } ``` ### Infrastructure Layer [Section titled “Infrastructure Layer”](#infrastructure-layer) **WordPressEnvironmentDetector** (`src/Application/Infrastructure/Services/WordPressEnvironmentDetector.php`) Uses WordPress’s native `wp_get_environment_type()` function: ```php class WordPressEnvironmentDetector implements EnvironmentDetectorInterface { public function getEnvironment(): string { return wp_get_environment_type() ?: 'production'; } public function isDevelopment(): bool { return $this->getEnvironment() === 'development'; } } ``` **LaravelEnvironmentDetector** (`src/Application/Infrastructure/Services/LaravelEnvironmentDetector.php`) Uses Laravel’s application instance for environment detection: ```php class LaravelEnvironmentDetector implements EnvironmentDetectorInterface { public function __construct(private readonly Application $app) {} public function getEnvironment(): string { return $this->app->environment() ?? 'production'; } public function isDevelopment(): bool { return $this->app->environment('local', 'development'); } } ``` ### Application Layer [Section titled “Application Layer”](#application-layer) **ApplicationEnvironmentService** (`src/Application/Application/Services/ApplicationEnvironmentService.php`) High-level service providing environment-based utilities: ```php class ApplicationEnvironmentService { public function __construct( private readonly EnvironmentDetectorInterface $detector ) {} public function shouldEnableDebug(): bool { return $this->isDevelopment() || $this->isStaging(); } public function shouldEnableCache(): bool { return $this->isProduction() || $this->isStaging(); } public function getConfigPrefix(): string { return match ($this->getEnvironment()) { 'development' => 'dev', 'staging' => 'stage', 'production' => 'prod', default => 'default', }; } } ``` ## Usage [Section titled “Usage”](#usage) ### Basic Environment Detection [Section titled “Basic Environment Detection”](#basic-environment-detection) ```php use Pollora\Application\Application\Services\ApplicationEnvironmentService; class MyController { public function __construct( private readonly ApplicationEnvironmentService $environment ) {} public function index() { if ($this->environment->isProduction()) { // Production-specific logic $this->enableCaching(); } if ($this->environment->isDevelopment()) { // Development-specific logic $this->enableDebugMode(); } } } ``` ### Service Container Resolution [Section titled “Service Container Resolution”](#service-container-resolution) The environment service is automatically registered and can be resolved in multiple ways: ```php // Via dependency injection public function __construct(ApplicationEnvironmentService $env) {} // Via service locator $environment = app(ApplicationEnvironmentService::class); // Via alias $environment = app('pollora.environment'); ``` ### Environment-Specific Configuration [Section titled “Environment-Specific Configuration”](#environment-specific-configuration) ```php class ConfigurationService { public function __construct( private readonly ApplicationEnvironmentService $environment ) {} public function getCacheSettings(): array { $prefix = $this->environment->getConfigPrefix(); return [ 'enabled' => $this->environment->shouldEnableCache(), 'ttl' => $this->environment->isProduction() ? 3600 : 60, 'prefix' => $prefix . '_cache_', ]; } } ``` ## Laravel Environment Configuration [Section titled “Laravel Environment Configuration”](#laravel-environment-configuration) Laravel environments are configured through the standard `.env` file: ```bash APP_ENV=local APP_DEBUG=true # Other environment-specific settings CACHE_DRIVER=file SESSION_DRIVER=file QUEUE_CONNECTION=sync ``` ## Performance Integration [Section titled “Performance Integration”](#performance-integration) The environment system is integrated with performance-critical components like the [Discovery Engine](/core-concepts/auto-discovery/): ```php class DiscoveryEngine { private function getCacheDriver(): DiscoverCacheDriver { // Disable cache in development for faster iteration if ($this->environment->isDevelopment() || $this->environment->isEnvironment('local')) { return new NullDiscoverCacheDriver(); } // Enable cache in production for performance return new LaravelDiscoverCacheDriver(); } } ``` ## Advanced Usage [Section titled “Advanced Usage”](#advanced-usage) ### Custom Environment Types [Section titled “Custom Environment Types”](#custom-environment-types) ```php // Check for custom environment if ($environment->isEnvironment('testing')) { $this->setupTestDatabase(); } // Environment-specific feature flags $features = [ 'debug_toolbar' => $environment->isDevelopment(), 'query_logging' => !$environment->isProduction(), 'cache_warmup' => $environment->isProduction(), ]; ``` ### Environment-Based Service Registration [Section titled “Environment-Based Service Registration”](#environment-based-service-registration) ```php class CustomServiceProvider extends ServiceProvider { public function register(): void { $environment = $this->app->make(ApplicationEnvironmentService::class); if ($environment->isDevelopment()) { $this->app->register(DevelopmentServiceProvider::class); } if ($environment->isProduction()) { $this->app->register(ProductionOptimizationProvider::class); } } } ``` # IDE Setup > Set up PhpStorm or Visual Studio Code for a Pollora project so that autocompletion and code navigation work across your Laravel and WordPress code. ## Introduction [Section titled “Introduction”](#introduction) If you don’t see WordPress native function suggestions in your IDE, it’s likely due to incomplete PHP include path configuration. Here’s how to configure the main IDEs to resolve this issue. ## PhpStorm [Section titled “PhpStorm”](#phpstorm) 1. Go to `File > Settings` (Windows/Linux) or `PhpStorm > Settings` (macOS) 2. Navigate to `PHP` configuration 3. Click on the `Include Path` tab 4. Add the following paths using the `+` button: * `public/cms` * `vendor` 5. Click `Apply` then `OK` 6. Restart PhpStorm for changes to take effect ## Visual Studio Code [Section titled “Visual Studio Code”](#visual-studio-code) 1. Open Settings with `Ctrl+,` (Windows/Linux) or `Cmd+,` (macOS) 2. Search for “php include path” 3. Under `PHP > Include Path`, add the following paths: ```json { "php.includePath": [ "./public/cms", "./vendor" ] } ``` 4. Restart VS Code for changes to take effect ## Important Notes [Section titled “Important Notes”](#important-notes) * Paths should be relative to your project root * After any include path modifications, it’s recommended to restart your IDE * If you’re using specific PHP plugins in VS Code (like PHP Intelephense), you might need to configure include paths in their respective settings as well ## Verification [Section titled “Verification”](#verification) To verify the configuration is correct: 1. Open a PHP file in your project 2. Try using a WordPress native function (e.g., `get_post()`) 3. Autocomplete should now suggest the function and display its documentation If suggestions still don’t appear, verify that: * Include paths are correctly written * Your IDE has been restarted * Your IDE’s PHP plugins are up to date # Installation > Install Pollora, the Laravel framework for WordPress, with the Pollora CLI or Composer, run the WordPress setup and check that your new project works. Welcome to Pollora! This guide will help you get a working installation up and running. Pollora is a WordPress framework built on top of Laravel — it replaces WordPress’s frontend templating with Laravel’s Blade engine while keeping WordPress’s full backend (admin, database, plugins). ## Requirements [Section titled “Requirements”](#requirements) * PHP 8.4 or higher (the skeleton’s `composer.lock` ships Symfony 8, which requires it) * Composer 2.x * MySQL 5.7+ or MariaDB 10.3+ (WordPress is loaded only on the `mysql` driver; SQLite is not supported) * Node.js and NPM (for theme asset bundling) * [DDEV](https://ddev.readthedocs.io) (optional, for a ready-made local environment) ## Current release [Section titled “Current release”](#current-release) Pollora’s version numbers follow the Laravel release it is built on, so the current line is **13.35**, built on Laravel 13.35. Two packages carry that number: `pollora/pollora`, the skeleton `create-project` installs, and `pollora/framework`, which the skeleton requires. They are tagged together, and a skeleton tag pins the framework tag of the same number in its `composer.lock` — which is what `create-project` installs from, whatever constraint the `composer.json` carries. So one version number describes an install completely. The current release is **v13.35.5**, a stable release: a plain `composer create-project pollora/pollora` installs it. ## Installation Methods [Section titled “Installation Methods”](#installation-methods) Pollora offers two ways to create a project: 1. The Pollora CLI (recommended) 2. Composer `create-project` Both end up running the same interactive setup, which you can also [run by hand](#running-the-setup-manually). ### 1. Pollora CLI [Section titled “1. Pollora CLI”](#1-pollora-cli) Install the CLI globally once: ```bash composer global require pollora/cli ``` Make sure Composer’s global `vendor/bin` directory is in your `PATH` — `composer global config bin-dir --absolute` prints it. Then create a project: ```bash pollora new example-app ``` Or let the CLI provision a full local environment with DDEV (recommended): ```bash pollora new example-app --ddev ``` With `--ddev`, the CLI configures DDEV (WordPress project type, PHP 8.4, MariaDB 10.11), starts it, installs the project inside the container, writes the database credentials to `.env`, and runs the WordPress installation. Your site is then available at `https://example-app.ddev.site`. #### CLI options [Section titled “CLI options”](#cli-options) | Option | Description | | --------------- | ------------------------------------------------------------------- | | `--ddev` | Set up the project with DDEV | | `--force`, `-f` | Force install even if the directory already exists | | `--git` | Initialize a Git repository | | `--branch=NAME` | Branch name for the new repository (default: `main`) | | `--ver=VERSION` | Install a specific version or constraint (e.g. `13.35.0`, `^13.35`) | | `--stable` | Install the latest stable release instead of the latest pre-release | `pollora new` installs the latest release **including pre-releases**: today that is the stable v13.35.5, and a beta published after it would be picked up. Pass `--stable` to never get a pre-release, or `--ver` to pin an exact version. ### 2. Composer create-project [Section titled “2. Composer create-project”](#2-composer-create-project) ```bash composer create-project pollora/pollora example-app ``` Composer picks the latest stable release. To try a pre-release, ask for it explicitly, e.g. `"pollora/pollora:^13.35@beta"`. Either command will: 1. Create a new Pollora project 2. Install all dependencies 3. Automatically launch the LaunchPad setup process During the setup, you’ll be prompted for: #### Environment Configuration (pollora:env:setup) [Section titled “Environment Configuration (pollora:env:setup)”](#environment-configuration-polloraenvsetup) * **Site URL**: Your site’s URL (e.g., ) * **Database Configuration**: * Host (default: localhost) * Port (default: 3306) * Database name * Username * Password The system will test the database connection. If it fails, you’ll have the option to retry with different credentials. #### WordPress Installation (pollora:install) [Section titled “WordPress Installation (pollora:install)”](#wordpress-installation-pollorainstall) * **Site Information**: * Site title * Site description * Language selection (searchable list of available languages) * **Admin Account**: * Username * Email * Password (minimum 8 characters) * **Search Engine Visibility**: * Option to allow or prevent search engine indexing * **Theme**: the name of the theme generated from `pollora/theme-default` (defaults to `default`) ## Running the setup manually [Section titled “Running the setup manually”](#running-the-setup-manually) If you prefer to run the installation steps yourself — or need to re-run them — use the following Artisan commands: ```bash # Configure environment php artisan pollora:env:setup # Install WordPress php artisan pollora:install ``` These commands guide you through the same interactive setup as the automatic installation. ### Non-Interactive Installation [Section titled “Non-Interactive Installation”](#non-interactive-installation) For automated deployments, CI/CD pipelines, or scripted setups, you can bypass the interactive prompts by passing all required options directly: ```bash php artisan pollora:install \ --title="My Site" \ --description="A Pollora-powered site" \ --admin-user=admin \ --admin-email=admin@example.com \ --admin-password=secretpassword \ --locale=en_US \ --public=true \ --theme=default ``` Available options: | Option | Description | | ------------------ | ----------------------------------------------------- | | `--title` | Site title | | `--description` | Site description | | `--admin-user` | Admin username | | `--admin-email` | Admin email address | | `--admin-password` | Admin password (min. 8 characters) | | `--locale` | Site locale (e.g. `en_US`, `fr_FR`) | | `--public` | Allow search engine indexing (`true` or `false`) | | `--theme` | Name of the theme to generate (defaults to `default`) | | `--install` | Suppress informational output for automated runs | Any option that is omitted will trigger its corresponding interactive prompt. This means you can mix CLI options and prompts — for example, provide the title and admin credentials via options while being prompted for language selection. ## Web-based Installation [Section titled “Web-based Installation”](#web-based-installation) If you prefer the traditional WordPress installation interface, you can: 1. Run the environment setup: ```bash php artisan pollora:env:setup ``` 2. Once the `.env` file is configured, visit your site’s URL and follow the WordPress installation wizard. ## Post-Installation Verification [Section titled “Post-Installation Verification”](#post-installation-verification) After successful installation: 1. Start the development server: `php artisan serve` (or, with DDEV, just open `https://example-app.ddev.site`) 2. Access your site at the configured URL 3. Access your WordPress admin panel at: `your-site-url/wp-admin` 4. Verify you can log in with the admin credentials you configured For post-installation configuration (WordPress settings, environment variables, development environments), see the [Configuration](/getting-started/configuration/) guide. **Heads up!** Pollora rids WordPress of frontend responsibilites altogether, this means theme support in WordPress is dropped completely. Don't worry though, any functions you can run in vanilla WordPress you can run in Pollora! ## See also [Section titled “See also”](#see-also) * [Why Pollora](https://pollora.dev/why/) * [How Pollora compares with Acorn, Sage, Radicle and Corcel](https://pollora.dev/compare/) * [Laravel and WordPress: every way to combine them](https://pollora.dev/guides/laravel-and-wordpress-approaches/) # Server Configuration > Serve a Pollora site with Apache or Nginx: the document root, protection against directory browsing, and HTTPS when running behind a reverse proxy. * [Overview](#overview) * [Document Root](#document-root) * [Directory Browsing Protection](#directory-browsing-protection) * [The Problem](#the-problem) * [Apache Configuration](#apache-configuration) * [Nginx Configuration](#nginx-configuration) * [Reverse Proxy & HTTPS](#reverse-proxy-and-https) * [How Pollora Handles HTTPS](#how-pollora-handles-https) * [TrustProxies Middleware](#trustproxies-middleware) []() ## Overview [Section titled “Overview”](#overview) Pollora uses a Bedrock-style directory layout where WordPress core, plugins, themes, and uploads all live under the `public/` document root. This differs from a standard WordPress installation and requires specific web server configuration to prevent exposing internal directories. []() ## Document Root [Section titled “Document Root”](#document-root) Your web server’s document root must point to the `public/` directory: ```plaintext your-project/ ├── app/ ├── bootstrap/ ├── config/ ├── public/ ← document root │ ├── index.php ← Laravel front controller │ ├── wp-config.php │ ├── cms/ ← WordPress core │ └── content/ ← wp-content (plugins, themes, uploads) ├── resources/ └── vendor/ ``` []() ## Directory Browsing Protection [Section titled “Directory Browsing Protection”](#directory-browsing-protection) []() ### The Problem [Section titled “The Problem”](#the-problem) Because `content/` (WordPress’s `wp-content`) is under the public document root, directories like `/content/plugins/`, `/content/themes/`, and `/content/uploads/` are web-accessible. By default, some of these directories contain `index.php` files with only a comment (`// Silence is golden.`), which causes the web server to return a **blank 200 response** — confirming the directory exists and leaking structural information. The fix is to ensure your web server **never resolves `DirectoryIndex`** for these paths, and instead routes all non-file requests through the Laravel front controller. []() ### Apache Configuration [Section titled “Apache Configuration”](#apache-configuration) Since v13.34.1, the `.htaccess` shipped with the skeleton (`public/.htaccess`) sends these requests to the front controller, which answers with your site’s own 404 page. A project created before that can add the same rule, **before** the “Redirect Trailing Slashes” block: ```apache # WordPress Content Directories Are Not Pages... RewriteCond %{REQUEST_FILENAME} -d [OR] RewriteCond %{REQUEST_FILENAME} /index\.php$ RewriteRule ^(cms/wp-content|content)(/|$) index.php [L] ``` This ensures that: * **Directories** under `/cms/wp-content/` and `/content/` — and the empty `index.php` WordPress ships in some of them — answer your site’s 404 instead of a blank 200 or a 403 * **Files** in them (plugin CSS and JS, uploads, fonts) are served directly by Apache, as before * **The rest of the site** is untouched: `/cms/wp-admin/` and the front controller keep their `index.php` `Options -Indexes`, also in the shipped `.htaccess`, keeps directory listing off everywhere else. []() ### Nginx Configuration [Section titled “Nginx Configuration”](#nginx-configuration) For Nginx, the key is to remove the `$uri/` directive from `try_files`, which prevents Nginx from resolving `DirectoryIndex` for arbitrary directories: ```nginx server { listen 80; server_name example.com; root /var/www/your-project/public; index index.php; # Serve static files directly, everything else goes to the framework. # Intentionally omit $uri/ to prevent DirectoryIndex resolution # for paths like /content/plugins/ or /content/themes/. location / { try_files $uri /index.php?$query_string; } # wp-admin needs DirectoryIndex resolution for its own index.php. location ^~ /cms/wp-admin { try_files $uri $uri/ /index.php?$query_string; } # PHP handling location ~ \.php$ { fastcgi_pass unix:/var/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } # Deny access to hidden files location ~ /\. { deny all; } } ``` > **Note**\ > The critical difference from a standard Laravel Nginx config is `try_files $uri /index.php?$query_string;` (without `$uri/`). The `$uri/` directive tells Nginx to look for an `index.php` inside the requested directory — which is exactly what causes the blank 200 responses. []() ## Reverse Proxy & HTTPS [Section titled “Reverse Proxy & HTTPS”](#reverse-proxy--https) []() ### How Pollora Handles HTTPS [Section titled “How Pollora Handles HTTPS”](#how-pollora-handles-https) When deployed behind a reverse proxy (Clever Cloud, Heroku, AWS ALB, Cloudflare, etc.), the backend server often receives requests over HTTP on an internal port (e.g., `127.0.0.1:8080`), while the public-facing URL uses HTTPS. Pollora derives the HTTPS state from the `APP_URL` environment variable — not from request headers. When `APP_URL` starts with `https://`, Pollora automatically: 1. Forces Laravel’s URL generator to use HTTPS (`URL::forceScheme('https')`) 2. Sets `$_SERVER['HTTPS'] = 'on'` so WordPress canonical redirects work correctly 3. Computes `WP_HOME`, `WP_SITEURL`, and `WP_CONTENT_URL` from `APP_URL` This means you **do not need** to manually configure `X-Forwarded-Proto` header mapping. Just ensure `APP_URL` is set correctly in your `.env`: ```env APP_URL=https://your-domain.com ``` []() ### TrustProxies Middleware [Section titled “TrustProxies Middleware”](#trustproxies-middleware) While Pollora handles HTTPS and WordPress URLs via `APP_URL`, you may still want to configure Laravel’s `TrustProxies` middleware for other features that rely on the original client request (client IP, rate limiting, etc.). In `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies( at: '*', // or specific proxy IPs headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_AWS_ELB, ); }) ``` > **Warning**\ > Using `at: '*'` trusts all proxies. In production, restrict this to your reverse proxy’s IP range when possible. # AI coding agents for WordPress projects > Give Claude Code, Cursor, Copilot or Codex accurate context on a WordPress codebase with Laravel Boost and Nectar: guidelines, agent skills, MCP tools. Coding agents such as Claude Code, Cursor, GitHub Copilot or Codex write code from their training and from what they can read in your project. On a WordPress codebase, both are often thin. This guide explains how [Laravel Boost](https://laravel.com/docs/boost) and [Nectar](/nectar/overview/) give an agent working on a Pollora project the context it is missing. This is not about the “WordPress MCP” servers that let an assistant create posts or edit pages on a live site. Those work on content. Boost and Nectar work on code: how your application is built, inspected while you develop. ## Why agents get WordPress wrong [Section titled “Why agents get WordPress wrong”](#why-agents-get-wordpress-wrong) A few properties of WordPress make it hard to infer from source code alone: * **Global state.** Much of WordPress is global functions and global variables (`$post`, `$wp_query`). What a template can rely on depends on where in the request it runs, which the code does not say. * **Hooks registered at runtime.** Behaviour is attached with `add_action()` and `add_filter()` from the theme, from plugins and from core. Reading your theme does not show which filters a plugin adds, or which post types exist once every plugin has loaded. * **Many eras of documentation.** Tutorials for the classic editor, the block editor, `functions.php` snippets and different PHP versions coexist on the web. An agent has no reliable way to tell which one matches your project. On a Pollora project there is one more gap: Pollora registers post types, taxonomies and hooks with PHP attributes and routes WordPress pages with `Route::wp()`. An agent that has not seen these conventions falls back on plain WordPress code (`register_post_type()` in `functions.php`), which works against the framework. ## How Laravel Boost works [Section titled “How Laravel Boost works”](#how-laravel-boost-works) Boost is Laravel’s package for AI-assisted development. It gives an agent three kinds of context ([Boost documentation](https://laravel.com/docs/13.x/boost)): * **AI guidelines.** Instruction files loaded upfront, when the agent starts, with the conventions of the packages you use. `boost:install` writes them into the files your agents read (`CLAUDE.md`, `AGENTS.md` and so on). * **Agent skills.** Focused modules, each a `SKILL.md` file, that the agent loads on demand when it works on a matching task. * **An MCP server.** Tools the agent can call to inspect the running application: application info, database connections, schema and queries, log entries, the last error, browser logs, absolute URLs, project rules, and `Search Docs`, which queries Laravel’s hosted documentation API for your installed package versions. Boost lists set-up steps for Cursor, Claude Code, Codex, Gemini CLI, GitHub Copilot (VS Code) and Junie. Third-party packages can ship their own guidelines in `resources/boost/guidelines/` and skills in `resources/boost/skills/`; Boost installs them alongside its own. Nectar uses that mechanism. Boost knows Laravel. It has no WordPress guidelines or skills, and its documentation API covers Laravel ecosystem packages, not WordPress. ## What Nectar adds [Section titled “What Nectar adds”](#what-nectar-adds) [Nectar](https://github.com/Pollora/Nectar) (`pollora/nectar`) is a Boost extension for Pollora projects. It adds the WordPress and Pollora side. ### Guidelines [Section titled “Guidelines”](#guidelines) Loaded upfront through Boost, they describe Pollora’s architecture (Laravel routes first, the WordPress template hierarchy as fallback, Blade only), the registration attributes (`#[PostType]`, `#[Taxonomy]`, `#[Action]`, `#[Filter]`, `#[Schedule]`, `#[WpRestRoute]`, `#[Ajax]`, `#[Ability]`, `#[SkipDiscovery]`), `Route::wp()`, Sage Directives in Blade, theme structure and the Artisan commands. A key rule they state: never call WordPress registration functions directly, use attributes and discovery. The guideline text is picked from your installed framework major (12.x or 13.x). ### Agent skills [Section titled “Agent skills”](#agent-skills) Nine skills, loaded when the task matches: | Skill | Covers | | -------------------- | --------------------------------------------------------- | | `pollora-post-types` | Custom post types with `#[PostType]` | | `pollora-taxonomies` | Custom taxonomies with `#[Taxonomy]` | | `pollora-theming` | Blade or block themes, Vite, Tailwind CSS, theme.json | | `pollora-hooks` | Actions and filters with attributes or facades | | `pollora-blocks` | Gutenberg blocks with JSX/TSX and Blade rendering | | `pollora-rest-api` | REST endpoints with `#[WpRestRoute]`, AJAX with `#[Ajax]` | | `pollora-scheduling` | Recurring tasks with `#[Schedule]` and WordPress cron | | `pollora-modules` | Laravel Modules (nwidart) with discovery | | `pollora-abilities` | The WordPress Abilities API with `#[Ability]` | ### MCP tools [Section titled “MCP tools”](#mcp-tools) Nectar runs its own MCP server, `pollora-nectar`, next to Boost’s. Its ten tools read the live environment, which is the part static code cannot show: | Tool | Returns | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `pollora_status` | PHP, Laravel, Pollora and WordPress versions, active theme, discovery cache status, installed Pollora packages | | `wordpress_info` | WordPress version, site URL, active and inactive plugins, active and parent theme, multisite, key constants, locale, permalink structure | | `post_types_info` | Registered post types and their configuration | | `taxonomies_info` | Registered taxonomies and their configuration | | `registered_hooks` | Hooks discovered from `#[Action]` and `#[Filter]`, with class, method and priority | | `active_theme_info` | Theme structure, providers, blocks, Blade templates, Vite and theme.json status | | `discovered_components` | All auto-discovered components, grouped by type | | `wordpress_routes` | Routes, including `Route::wp()` conditions | | `modules_info` | Installed Laravel Modules and their status | | `wp_option` | One WordPress option value by key | Every tool is marked read-only: none of them changes the application. ### Upgrade prompts [Section titled “Upgrade prompts”](#upgrade-prompts) Nectar also exposes MCP prompts that walk an agent through a Pollora upgrade step by step. Each registers only when the installed framework version matches: * `upgrade-pollora-v13`, for projects on Pollora 12.x moving to 13. * `upgrade-pollora-v13-32`, for projects on 13.0 up to, but not including, 13.32. It covers the dependency changes, `cweagans/composer-patches` 2, renamed Artisan commands, extracted packages, blocks, skeleton files and theme changes. ## Install and configure [Section titled “Install and configure”](#install-and-configure) Nectar requires Laravel Boost, so one Composer command installs both: ```bash composer require pollora/nectar --dev ``` Then let Boost write the guidelines and skills for your agents: ```bash php artisan boost:install ``` Select `pollora/nectar` when Boost asks about third-party packages, or add it to `boost.json` and update: ```json { "packages": ["pollora/nectar"] } ``` ```bash php artisan boost:update ``` Register the Nectar MCP server in the project’s `.mcp.json`, next to the `laravel-boost` entry Boost creates: ```json { "mcpServers": { "pollora-nectar": { "command": "php", "args": ["artisan", "nectar:mcp"] } } } ``` In a DDEV project, run the server inside the container instead: ```json { "mcpServers": { "pollora-nectar": { "command": "ddev", "args": ["exec", "php", "artisan", "nectar:mcp"] } } } ``` For an agent that does not read `.mcp.json`, register the same command (`php`, arguments `artisan nectar:mcp`) in its MCP settings. After a `composer update`, run `php artisan boost:update` again to refresh guidelines and skills; `--discover` also offers those of newly installed packages. Nectar loads only when `APP_ENV` is `local` or `development`. Set `NECTAR_ENABLED=false` to turn it off locally. ## Example prompts [Section titled “Example prompts”](#example-prompts) With guidelines, skills and tools in place, prompts can stay short: * *“Add an `event` post type with a `venue` taxonomy and show it in the REST API.”* The post-types and taxonomies skills point the agent to attribute classes such as `#[PostType('event')]` and `#[ShowInRest]` instead of `register_post_type()`. It can then call `post_types_info` to check that the type is registered. * *“Why doesn’t my filter on `the_content` run?”* The agent can list discovered hooks with `registered_hooks`, compare priorities, and check which plugins are active with `wordpress_info`. * *“Render single events with a controller.”* The guidelines describe `Route::wp('single', ...)`; `wordpress_routes` shows which routes already exist. * *“Create a hero block with inner blocks.”* The blocks skill describes the `resources/views/blocks` layout, `render.blade.php` and ``. * On a 13.4 project, ask the agent to use the `upgrade-pollora-v13-32` prompt to plan the upgrade. ## Limits [Section titled “Limits”](#limits) * **Pollora only.** Boost runs in a Laravel application, and Nectar requires `pollora/framework`. Neither works on a standard WordPress install without Pollora. * **Development only.** Nectar does not load in production or staging environments. * **Read-only introspection.** Nectar’s tools report state; you still review what the agent writes. * **Discovery-based hooks.** `registered_hooks` lists hooks declared with Pollora attributes. Hooks a third-party plugin adds with `add_action()` do not appear there. * **No WordPress documentation search.** Boost’s `Search Docs` covers Laravel ecosystem packages. For WordPress core APIs, the agent relies on its training and on the code. * **Upgrade prompts are narrow.** They exist for 12.x to 13 and for 13.0–13.31 to 13.32. A project already on 13.34 sees neither. To start a project with this set-up, see [Installation](/getting-started/installation/). The full reference is on the [Nectar overview](/nectar/overview/) page. # Blade templates in WordPress > Use Laravel Blade in a WordPress theme: the template hierarchy mapped to Blade views, layouts, components, WordPress directives, view composers and blocks. Blade is Laravel’s template engine: layouts, sections, components and `{{ }}` output that is escaped by default. WordPress themes use plain PHP files. This guide explains how a Pollora theme uses Blade for the whole front end while keeping the WordPress template hierarchy, then builds a minimal working theme. ## Plain WordPress templates vs Blade [Section titled “Plain WordPress templates vs Blade”](#plain-wordpress-templates-vs-blade) A classic `single.php` mixes the header, the loop and the footer in PHP tags: single.php ```php

``` The same template in Blade extends a layout and fills a section: resources/views/single.blade.php ```blade @extends('layouts.app') @section('content') @posts

@title

@content
@endposts @endsection ``` The layout is written once. `{{ $value }}` escapes its output, so forgetting `esc_html()` is no longer the default failure. Views are compiled to cached PHP, and you get Blade’s components, `@include`, `@foreach` and the rest of the syntax. ## How Pollora finds the Blade view [Section titled “How Pollora finds the Blade view”](#how-pollora-finds-the-blade-view) WordPress still decides which template a request needs. Pollora changes where it looks and how the result is rendered. 1. `RegisterTemplateHierarchyFiltersUseCase` (`src/View/Application/UseCases/`) hooks every `*_template_hierarchy` filter: `single`, `page`, `archive`, `category`, `taxonomy`, `404`, `index` and the rest. 2. For each candidate, `FileSystemTemplateFinder::locate()` swaps `.php` for `.blade.php` and looks for the file in the theme’s view paths. Blade matches are put ahead of PHP files. 3. When no [Laravel route](/routing/wordpress-routes/) matches the request, `FrontendController` asks WordPress for the template, applies `template_include`, converts the path to a view name and renders it with `View::make()`. The view paths of a theme are `resources/views`, `views` and the theme root, in that order (`ModuleAssetManager::getModuleViewPaths()`). In practice you put Blade files in `resources/views/`. WordPress’s names map directly: | Request | WordPress candidates | Blade view | | ------------------ | --------------------------------------------------------- | ------------------------------------------------ | | Single `book` post | `single-book-{slug}.php`, `single-book.php`, `single.php` | `single-book.blade.php`, then `single.blade.php` | | Page | `page-{slug}.php`, `page-{id}.php`, `page.php` | `page-about.blade.php`, then `page.blade.php` | | Category | `category-{slug}.php`, `category.php`, `archive.php` | `category.blade.php`, then `archive.blade.php` | | Not found | `404.php` | `404.blade.php` | | Anything else | `index.php` | `index.blade.php` | `index.blade.php` is the last resort. For a 404, if the theme has no `404.blade.php`, Pollora renders Laravel’s `errors.404` view instead of the index, with a 404 status. Custom page templates use a Blade comment instead of a PHP header. Pollora scans the view paths for it and adds the template to the editor’s list (`WordPressTemplateHierarchyFilter::extendThemeTemplates()`): resources/views/templates/landing.blade.php ```blade {{-- Template Name: Landing page --}} {{-- Template Post Type: page, post --}} @extends('layouts.app') ``` ## Layouts and components [Section titled “Layouts and components”](#layouts-and-components) Layouts are ordinary Blade views. WordPress’s `wp_head()` and `wp_footer()` must still run, because plugins and enqueued assets depend on them: resources/views/layouts/app.blade.php ```blade @wphead @yield('content') @wpfooter ``` Components work as in Laravel. Laravel looks up anonymous components as `components.*` views through the same view finder, so a file at `resources/views/components/card.blade.php` in the theme is available as ``. ## WordPress directives [Section titled “WordPress directives”](#wordpress-directives) `@posts`, `@title` and `@wphead` above are not core Blade. They come from [Sage Directives](https://github.com/Log1x/sage-directives) (`log1x/sage-directives` 2.0), which `pollora/framework` requires and registers in `PolloraServiceProvider`. Some of the WordPress ones: * Loop: `@posts` / `@endposts` (the main query, a `WP_Query`, a post, an ID or an array of IDs), `@hasposts`, `@noposts`, `@query`. * Post data: `@title`, `@content`, `@excerpt`, `@permalink`, `@thumbnail`, `@published`, `@modified`, `@author`, `@postmeta`. * Users: `@user` / `@enduser`, `@guest` / `@endguest`, `@role`. * Theme: `@wphead`, `@wpfooter`, `@bodyclass`, `@wpbodyopen`, `@menu`, `@sidebar`, `@shortcode`. * ACF: `@field`, `@hasfield`, `@fields`, `@sub`, `@option`, and others. A theme can add its own directives in `resources/directives.php`, which returns an array of names and compiler callbacks. Pollora loads it when the theme registers (`ModuleAssetManager::registerModuleBladeDirectives()`): resources/directives.php ```php fn (): string => '', ]; ``` ## Passing data to views [Section titled “Passing data to views”](#passing-data-to-views) Most templates can read WordPress data directly through the directives and template tags. When a page needs real logic, there are two Laravel ways to hand data to a view. A controller on a [WordPress route](/routing/controllers/). Type-hinted `WP_Post`, `WP_Term`, `WP_User` or `WP_Query` parameters are filled from the current query: routes/web.php ```php use Illuminate\Support\Facades\Route; Route::wp('single', function (WP_Post $post) { return view('single', [ 'related' => get_posts(['post__not_in' => [$post->ID], 'numberposts' => 3]), ]); }); ``` A view composer, for data every page needs. The default theme (`pollora/theme-default`) builds its navigation this way in `app/Providers/MenuServiceProvider.php`: app/Providers/MenuServiceProvider.php ```php View::composer('*', function ($view) { if (! $view->offsetExists('menu')) { $view->with('menu', $this->getMenu('primary')); } }); ``` ## Blade for blocks [Section titled “Blade for blocks”](#blade-for-blocks) Dynamic Gutenberg blocks can render with Blade too. Point `render` in `block.json` at a Blade file: resources/views/blocks/call-to-action/block.json ```json { "name": "default/call-to-action", "render": "file:./render.blade.php" } ``` `BlockRegistrar` renders the file with Laravel’s view factory and passes `$attributes`, `$content`, `$block` and `$isPreview`: resources/views/blocks/call-to-action/render.blade.php ```blade

{{ $attributes['heading'] ?? '' }}

``` `php artisan pollora:make:block` generates the full set (block.json, JSX, styles, render file). See [Gutenberg blocks](/blocks/gutenberg-blocks/). ## A minimal working theme [Section titled “A minimal working theme”](#a-minimal-working-theme) In a Pollora project, themes live in `themes/`. This is the smallest set of files that renders posts and pages with Blade: ```plaintext themes/minimal/ ├── style.css ├── functions.php ├── index.php ├── theme.json └── resources/views/ ├── layouts/app.blade.php ├── index.blade.php └── single.blade.php ``` `style.css` holds the WordPress theme header, and `index.php` is an empty stub that WordPress needs to treat the folder as a theme. `theme.json` is required as well: Pollora only lists a theme as available when it has one (`ThemeMetadata::getConfigPath()`). `{"version": 2}` is enough to start. style.css ```css /* Theme Name: Minimal */ ``` `functions.php` hands the theme to Pollora, which registers its view paths, directives and discovered classes: functions.php ```php @title @excerpt @endposts @noposts

Nothing found.

@endnoposts @endsection ``` Activate the theme in wp-admin. For a real project, `php artisan pollora:make:theme` creates a complete theme with Vite and Tailwind CSS. The [theme structure](/theming/theme-structure/) page describes it. ## Alternatives [Section titled “Alternatives”](#alternatives) Pollora is not the only way to get Blade into WordPress. [Sage](https://github.com/roots/sage), from Roots, is a mature starter theme with Blade and Tailwind CSS, powered by Acorn. It fits a normal WordPress site where WordPress stays in charge, and it is far more widely used than Pollora. [Timber](https://github.com/timber/timber) brings Twig templates to WordPress without Laravel and has a large community. Pollora’s Blade support comes with a Laravel application around it: routing, controllers, Eloquent and queues. [How Pollora compares](/compare/) covers the differences in detail, and [Why Pollora](/why/) explains the approach. To start, follow the [installation guide](/getting-started/installation/). # Custom post types and taxonomies with PHP attributes > Register a WordPress custom post type and taxonomy as PHP classes with attributes: labels, archives, REST API, translation and queries. Custom post types and taxonomies are how WordPress models content beyond posts and pages: books, events, products, and the categories that group them. This guide recaps the plain WordPress approach, then builds a `Book` post type and a `Genre` taxonomy as PHP classes with attributes in Pollora, and finishes with querying and displaying them. ## The plain WordPress way [Section titled “The plain WordPress way”](#the-plain-wordpress-way) In WordPress you call `register_post_type()` and `register_taxonomy()` on the `init` hook, usually from `functions.php` or a plugin: functions.php ```php add_action('init', function () { register_post_type('book', [ 'labels' => [ 'name' => __('Books', 'my-theme'), 'singular_name' => __('Book', 'my-theme'), 'add_new_item' => __('Add New Book', 'my-theme'), 'edit_item' => __('Edit Book', 'my-theme'), 'new_item' => __('New Book', 'my-theme'), 'view_item' => __('View Book', 'my-theme'), 'search_items' => __('Search Books', 'my-theme'), 'not_found' => __('No books found', 'my-theme'), 'not_found_in_trash' => __('No books found in Trash', 'my-theme'), 'all_items' => __('All Books', 'my-theme'), // ...about twenty more keys exist ], 'public' => true, 'has_archive' => 'books', 'rewrite' => ['slug' => 'books', 'with_front' => false], 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'revisions'], 'show_in_rest' => true, 'rest_base' => 'books', 'menu_icon' => 'dashicons-book', ]); register_taxonomy('genre', ['book'], [ 'labels' => [/* the same exercise again */], 'hierarchical' => true, 'public' => true, 'show_in_rest' => true, 'show_admin_column' => true, 'rewrite' => ['slug' => 'genre'], ]); }); ``` Nothing is wrong with this code, but it has known pain points. The labels array is long and mostly mechanical: the same noun in a dozen sentences. The arguments are an untyped array, so a typo such as `'show_in_reset'` is silently ignored. And the registration lives in a callback that someone has to remember to include. Some projects wrap this in a class (`class BookPostType { public function register() { ... } }`), which organises the code but still needs an `add_action('init', ...)` and a manual `new BookPostType()` somewhere. ## The attribute approach [Section titled “The attribute approach”](#the-attribute-approach) With PHP 8 attributes, the class itself is the declaration. Each WordPress argument becomes a typed attribute, and the framework reads them and calls `register_post_type()` for you. Pollora ships one attribute per argument, under `Pollora\Attributes\PostType` and `Pollora\Attributes\Taxonomy`. ### The Book post type [Section titled “The Book post type”](#the-book-post-type) app/Cms/PostTypes/Book.php ```php 'books', 'with_front' => false])] #[Supports(['title', 'editor', 'excerpt', 'thumbnail', 'revisions'])] #[ShowInRest] #[RestBase('books')] #[MenuIcon('dashicons-book')] class Book { } ``` What each line does: * `#[PostType('book')]` names the post type. The slug, singular and plural names are optional: without arguments they come from the class name (`Book` gives `book`, `Book`, `Books`). You can pass them explicitly with `singular:` and `plural:`. * `#[PublicPostType]` sets `public`. Like most boolean attributes, it defaults to `true` and accepts `false`. * `#[HasArchive('books')]` enables the archive at `/books/`. `#[HasArchive]` alone uses the default archive slug. * `#[Rewrite]` controls single URLs, here `/books/{slug}/`. * `#[Supports]` lists the editor features. * `#[ShowInRest]` exposes the post type in the REST API, which the block editor needs. `#[RestBase('books')]` sets the route to `/wp-json/wp/v2/books`. You never write the labels array. Pollora generates the full set from the singular and plural names, using translatable patterns such as `sprintf(__('Edit %s', 'my-theme'), 'Book')`. You can also scaffold the class: ```bash php artisan pollora:make:post-type Book ``` ### The Genre taxonomy [Section titled “The Genre taxonomy”](#the-genre-taxonomy) app/Cms/Taxonomies/Genre.php ```php 'genre', 'with_front' => false])] class Genre { } ``` `objectType` links the taxonomy to the `book` post type. It accepts a string or an array, and defaults to `['post']` when omitted. `#[Hierarchical]` makes genres behave like categories (parent and child terms, checkboxes in the editor) rather than tags. `#[ShowAdminColumn]` adds a Genre column to the Books list screen. Generate it with `php artisan pollora:make:taxonomy Genre` if you prefer. ### No registration step [Section titled “No registration step”](#no-registration-step) Both classes are found by [auto-discovery](/core-concepts/auto-discovery/): Pollora scans `app/` for classes carrying `#[PostType]` or `#[Taxonomy]` and registers them on WordPress’s `init` hook. There is no service provider to edit and no file to include. `app/Cms/PostTypes` and `app/Cms/Taxonomies` are where the generators write, but any location under `app/` works. As with any new post type, visit **Settings > Permalinks** once (or run `wp rewrite flush`) so WordPress rebuilds its rewrite rules and the `/books/` URLs resolve. ### Labels and translation [Section titled “Labels and translation”](#labels-and-translation) PHP attributes only accept constant expressions, so `__()` cannot appear inside one. That gives three options: * **Keep the generated labels.** They are translatable through the patterns above, with your `textDomain`. * **Override a few labels statically** with `#[Labels(addNew: 'New Book', notFound: 'No books yet.')]` from `Pollora\Attributes\PostType\Labels`. These strings are not translatable. * **Return translated labels from `withArgs()`.** Any array it returns is merged into the registration arguments, and `__()` runs at runtime, so `wp i18n make-pot` can extract the strings: app/Cms/PostTypes/Book.php ```php class Book { public function withArgs(): array { return [ 'labels' => [ 'name' => __('Books', 'my-theme'), 'singular_name' => __('Book', 'my-theme'), 'add_new_item' => __('Add New Book', 'my-theme'), ], ]; } } ``` A fourth option, a `configuring()` method, receives the post type entity and sets arguments fluently. Labels you pass there are merged with the generated ones, which suits translating only a few of them, and the method can also hold logic that depends on runtime state. See [Post Types](/content/post-types/#the-configuring-lifecycle-hook). ## Querying books [Section titled “Querying books”](#querying-books) Once registered, `book` is an ordinary WordPress post type, so everything you know still works: `WP_Query`, `get_posts()`, the REST API, the admin screens. ```php $query = new WP_Query([ 'post_type' => 'book', 'tax_query' => [[ 'taxonomy' => 'genre', 'field' => 'slug', 'terms' => 'fantasy', ]], ]); ``` Pollora also includes Eloquent models over the WordPress tables. `Pollora\Models\Post` has query scopes for post type, status and taxonomy: ```php use Pollora\Models\Post; $books = Post::type('book') ->published() ->taxonomy('genre', 'fantasy') ->newest() ->take(12) ->get(); ``` ### Displaying them [Section titled “Displaying them”](#displaying-them) You can display books in two ways. With no route defined, Pollora follows the WordPress template hierarchy with Blade views: an `archive-book.blade.php` view renders `/books/`, `single-book.blade.php` renders a single book, and `taxonomy-genre.blade.php` renders a genre page. Each falls back to the more generic view, as in WordPress. When you want a controller, route on the WordPress conditional tags with `Route::wp()`: routes/web.php ```php use App\Http\Controllers\BookController; use Illuminate\Support\Facades\Route; Route::wp('post-type-archive', 'book', [BookController::class, 'index']); Route::wp('singular', 'book', [BookController::class, 'show']); ``` app/Http/Controllers/BookController.php ```php Post::type('book')->published()->newest()->paginate(12), ]); } public function show(\WP_Post $post) { return view('books.show', ['book' => $post]); } } ``` The `WP_Post` for the current request is injected from its type hint. See [WordPress routes](/routing/wordpress-routes/) and [Controllers](/routing/controllers/). ## Every attribute in one place [Section titled “Every attribute in one place”](#every-attribute-in-one-place) This guide used a handful of attributes. Pollora has one for nearly every `register_post_type()` argument, including capabilities, menu position, search exclusion, block templates and admin columns. They are listed in the [Post Type Attributes Reference](/content/post-types-reference/). Taxonomy attributes, including `#[DefaultTerm]`, `#[Exclusive]` and the meta box callbacks, are listed on [Taxonomies](/content/taxonomies/). ## Next steps [Section titled “Next steps”](#next-steps) * [Post Types](/content/post-types/): `withArgs()`, `configuring()` and internationalization in detail * [Post Type Attributes Reference](/content/post-types-reference/) * [Taxonomies](/content/taxonomies/) * [Auto-discovery](/core-concepts/auto-discovery/) * [WordPress hooks with PHP 8 attributes](/guides/wordpress-hooks-php-attributes/) * [Installation](/getting-started/installation/) * [Pollora compared with Acorn, Sage, Radicle and Corcel](/compare/) # How Pollora runs WordPress inside Laravel > A request through Pollora, step by step: Laravel boots, WordPress loads in a service provider, and Route::wp() or the template hierarchy picks the view. Pollora is a Laravel application that loads WordPress as part of its own boot. Laravel receives the request, WordPress core runs inside a service provider, and the Laravel router decides what to render, using WordPress’s conditional tags and template hierarchy. The admin, the REST API and plugins keep working. This article follows one request through that pipeline and then looks at the paths that do not go through it: wp-admin, wp-login, REST, AJAX and cron. Paths starting with `src/` are in [`pollora/framework`](https://github.com/Pollora/framework); the others are in the `pollora/pollora` skeleton. Excerpts are trimmed but not rewritten. ## The layout [Section titled “The layout”](#the-layout) The skeleton is a Laravel application with WordPress installed by Composer under the document root: * `public/index.php` is Laravel’s front controller. * `public/cms/` holds WordPress core (`"wordpress-install-dir": "public/cms"` in `composer.json`). * `public/content/` replaces `wp-content`: plugins, themes, uploads. * `public/wp-config.php` is the file WordPress’s own scripts load. It boots Laravel. The `.htaccess` sends every request that is not a real file or directory to `index.php`. So `/`, `/blog/hello-world` and `/wp-json/wp/v2/posts` reach Laravel, while `/cms/wp-login.php` and `/cms/wp-admin/` are PHP files that the server runs directly. ## 1. The entry point [Section titled “1. The entry point”](#1-the-entry-point) `public/index.php` is the stock Laravel 13 front controller: public/index.php ```php require __DIR__.'/../vendor/autoload.php'; (require_once __DIR__.'/../bootstrap/app.php') ->handleRequest(Request::capture()); ``` Nothing WordPress-specific happens here. The framework is wired in through Laravel package discovery: `pollora/framework` declares `Pollora\Providers\PolloraServiceProvider`, which registers about forty providers (`src/Providers/PolloraServiceProvider.php`). The one that matters for this article is `WordPressServiceProvider`, registered near the end of that list. ## 2. WordPress configuration comes from Laravel [Section titled “2. WordPress configuration comes from Laravel”](#2-wordpress-configuration-comes-from-laravel) During `register()`, before any provider boots, `src/WordPress/Bootstrap.php` defines the constants that would normally live in `wp-config.php`. Values come from Laravel config, and anything in `config('wordpress.constants')` is queued after the defaults, so it replaces them: src/WordPress/Bootstrap.php ```php Constant::queue('WP_USE_THEMES', ! $this->consoleDetectionService->isConsole() && ! str_starts_with((string) request()->server('REQUEST_URI'), '/cms/')); Constant::queue('WP_AUTO_UPDATE_CORE', false); Constant::queue('DISABLE_WP_CRON', true); Constant::queue('WP_POST_REVISIONS', 5); $debugMode = $this->debugDetector->isDebugMode(); Constant::queue('WP_DEBUG', $debugMode); foreach ((array) config('wordpress.constants', []) as $key => $value) { Constant::queue(strtoupper((string) $key), $value); } Constant::apply(); ``` Paths and URLs are derived from `APP_URL`: `ABSPATH` is `public/cms/`, `WP_SITEURL` is `APP_URL/cms`, `WP_HOME` is `APP_URL`, and `WP_CONTENT_DIR`/`WP_CONTENT_URL` point to `public/content`. In `boot()`, the database constants (`DB_NAME`, `DB_USER`, `DB_HOST` with the port appended, and so on) are copied from `DB::getConfig()`, so WordPress and Eloquent use the same connection settings. The constant manager skips any constant that is already defined (`src/WordPress/Config/ConstantManager.php`). The authentication keys and salts come from `.env` through `config/wordpress.php`. `register()` also loads WordPress’s plugin API early, so `add_filter()` exists before WordPress itself does: src/WordPress/Bootstrap.php ```php private function ensureAddFilterExists(): void { if (! function_exists('add_filter')) { require_once ABSPATH.'/wp-includes/plugin.php'; } } ``` This lets providers that boot earlier, such as hook discovery, attach callbacks before `wp-settings.php` runs. WordPress loads `plugin.php` with `require_once`, so it is not included twice. ## 3. WordPress core loads inside a service provider [Section titled “3. WordPress core loads inside a service provider”](#3-wordpress-core-loads-inside-a-service-provider) `WordPressServiceProvider::boot()` calls `Bootstrap::boot()`. If the database is configured (the check requires the `mysql` driver and opens a PDO connection), it requires WordPress: src/WordPress/Bootstrap.php ```php private function loadWordPressSettings(): void { global $wp_version; global $wp_db_version; // ... global $wp_filter; global $wp_actions; if ($this->consoleDetectionService->isConsole() && ! $this->isWordPressInstalled()) { define('SHORTINIT', true); } if ($this->isLightweightRequest()) { $this->applyLightweightFilters(); } if (! $this->consoleDetectionService->isWpCli()) { require_once ABSPATH.'wp-settings.php'; } } ``` The `global` declarations are there because a file required from inside a method runs in that method’s scope. Declaring WordPress’s core globals first makes the assignments in `wp-settings.php` land in the global scope where the rest of WordPress expects them. Two other details from the same file: * The call is wrapped in `withWordPressErrorHandling()`, which swallows `E_DEPRECATED` notices from WordPress and plugins and forwards everything else to Laravel’s handler. * Requests under `/api/` are “lightweight”. By default `pre_option_active_plugins` returns an empty array, so no plugins load. `config('wordpress.api_plugins')` can allow a list of plugins (glob patterns accepted), or all of them with `['*']`. By the end of `wp-settings.php`, plugins, the active theme’s `functions.php`, `init` and `wp_loaded` have run, all inside Laravel’s provider boot. ## 4. WordPress parses the request [Section titled “4. WordPress parses the request”](#4-wordpress-parses-the-request) Still in `boot()`, if WordPress is installed and this is not a console run, `runWp()` runs the main query: src/WordPress/QueryTrait.php ```php if ($this->laravelIsServingTheRequest()) { wp(); if (wp_using_themes()) { $this->action->do('template_redirect'); } if (is_robots()) { $this->action->do('do_robots'); exit; } // is_favicon(), is_feed() and is_trackback() are handled the same way } $this->action->do('pollora_loaded'); ``` `wp()` parses the URL against WordPress’s rewrite rules and fills `$wp_query`. From here on, `is_single()`, `is_page()` and the other conditional tags return real answers. `template_redirect` fires, so canonical redirects and plugin redirects still happen. Robots, favicon, feeds and trackbacks are answered by WordPress and the script exits. What WordPress does not do is include `template-loader.php`. Rendering is left to Laravel. `laravelIsServingTheRequest()` compares `$_SERVER['SCRIPT_FILENAME']` with `public/index.php`. That check matters for the admin paths described below. ## 5. The router matches on conditional tags [Section titled “5. The router matches on conditional tags”](#5-the-router-matches-on-conditional-tags) After the providers have booted, the HTTP kernel dispatches the request to the router. Pollora replaces Laravel’s router with `ExtendedRouter`, which creates its own `Route` model, and adds two macros, `Route::wp()` and `Route::wpMatch()`. A WordPress route stores a condition instead of matching a URI: src/Route/Infrastructure/Models/Route.php ```php public function matches(Request $request, $includingMethod = true): bool { $this->compileRoute(); if ($this->isWordPressRoute() && $this->hasCondition()) { return $this->matchesWordPressCondition(); } return parent::matches($request, $includingMethod); } ``` `matchesWordPressCondition()` calls the conditional function with any extra arguments, so `Route::wp('page', 'contact', ...)` runs `is_page('contact')`. Aliases such as `single`, `front` or `tax` resolve to `is_single`, `is_front_page` or `is_tax` through `WordPressConditionManager` and the `conditions` key of `config/wordpress.php`. Routes are tried in the order they were registered, so ordinary URI routes and `Route::wp()` routes in `routes/web.php` behave as you would expect from Laravel. routes/web.php ```php Route::wp('single', [BlogController::class, 'show']); Route::wp('page', 'contact', [ContactController::class, 'show']); ``` WordPress routes get three middleware. `WordPressBindings` injects the current `WP_Post`, `WP_Term`, `WP_User`, `WP_Query` or `WP` object into any controller parameter type-hinted with one of those classes. The other two are described in step 7. See [WordPress routes](/routing/wordpress-routes/) and [controllers](/routing/controllers/) for the full syntax. When a plain Laravel route such as `/dashboard` matches, WordPress has already parsed that URL and marked it as a 404. The `ApplyApplicationRouteContext` listener resets `$wp_query->is_404` and adds body classes derived from the route, so the page does not carry WordPress’s `error404` state. ## 6. The template hierarchy is the fallback [Section titled “6. The template hierarchy is the fallback”](#6-the-template-hierarchy-is-the-fallback) If nothing else matches, a catch-all route registered once the application has booted takes the request: src/Route/Infrastructure/Providers/RouteServiceProvider.php ```php $route = Route::any('{any}', [FrontendController::class, 'handle']) ->where('any', '^(?!api/).*') ->middleware(self::WORDPRESS_MIDDLEWARE); ``` `FrontendController` repeats what WordPress’s `template-loader.php` does: it walks `is_404`, `is_search`, `is_front_page`, `is_single`, `is_page`, `is_archive` and the rest, calls the matching `get_*_template()` function, falls back to `get_index_template()`, and applies the `template_include` filter. The difference is the last step: the resulting file path is converted to a view name and rendered with `View::make()`, with a 404 status when `is_404()` is true. Blade templates enter the hierarchy through filters. `RegisterTemplateHierarchyFiltersUseCase` hooks every `*_template_hierarchy` filter, and `FileSystemTemplateFinder::locate()` turns each candidate such as `single-book.php` into `single-book.blade.php` and looks for it in the theme’s view paths. Blade candidates are ranked ahead of PHP ones. The [Blade templates guide](/guides/blade-templates-wordpress/) covers the theme side. ## 7. After the controller [Section titled “7. After the controller”](#7-after-the-controller) Two middleware act on the response: * `WordPressHeaders` adds `X-Powered-By: Pollora` (can be turned off) and, for visitors who are not logged in, replaces WordPress’s no-cache headers on HTML responses with `public, must-revalidate, max-age=…`. The default is 3600 seconds (`WP_CACHE_MAX_AGE`), with optional per-condition TTLs (`wordpress.cache.ttl`) and `s-maxage`. It respects `DONOTCACHEPAGE` and any `max-age`, `s-maxage` or `no-store` the application set itself. * `WordPressShutdown` runs WordPress’s `shutdown` action inside an output buffer, injects anything it printed before ``, then removes all `shutdown` callbacks so they do not run twice. ## Requests that skip the Laravel router [Section titled “Requests that skip the Laravel router”](#requests-that-skip-the-laravel-router) **wp-admin and wp-login.** PHP runs `public/cms/wp-login.php` or a file under `public/cms/wp-admin/` directly. WordPress’s `wp-load.php` looks for `wp-config.php` in `ABSPATH`, then one directory up, and finds `public/wp-config.php`. That file boots Laravel by hand: public/wp-config.php ```php $app = require_once __DIR__.'/../bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); $app->bootstrapWith([ LoadEnvironmentVariables::class, LoadConfiguration::class, HandleExceptions::class, RegisterFacades::class, SetRequestForConsole::class, ]); $app->instance('request', Request::capture()); $app->bootstrapWith([ Illuminate\Foundation\Bootstrap\RegisterProviders::class, Illuminate\Foundation\Bootstrap\BootProviders::class, ]); ``` Booting the providers runs `Bootstrap::boot()`, which requires `wp-settings.php`. Control then returns to the admin script, and the HTTP kernel never handles the request. Because the running script is not `public/index.php`, `runWp()` skips `wp()`. That avoids a real bug recorded in the framework changelog: `/cms/wp-login.php` matched the attachment rewrite rule and was sent with a 404 status. `WP_USE_THEMES` is false for `/cms/` URLs. A `template_include` filter at `PHP_INT_MAX` redirects to the home URL if a `.blade.php` file is about to be included outside Laravel, rather than printing Blade source. **REST API.** `/wp-json/...` is not a file, so it goes through `index.php`. WordPress core hooks `rest_api_loaded` to `parse_request`, so the `wp()` call in step 4 serves the REST response and calls `die()`. This happens during provider boot, before the Laravel router runs. The skeleton excludes `wp-json/*` from CSRF checks and from the trailing-slash redirect in `.htaccess`. Custom endpoints can be declared with `#[WpRestRoute]` ([REST API](/advanced/rest-api/)). **AJAX.** `admin-ajax.php` lives in `wp-admin`, so it follows the admin path. The `Ajax` facade and `#[Ajax]` attribute register ordinary `wp_ajax_*` hooks ([AJAX](/advanced/ajax/)). **Cron.** `DISABLE_WP_CRON` is true by default, so page views do not trigger WordPress’s pseudo-cron. With `wordpress.use_laravel_scheduler` enabled, `src/Schedule/` intercepts WordPress’s cron storage (`pre_schedule_event`, `pre_option_cron` and related filters) and runs events as Laravel jobs and scheduled callbacks ([scheduling](/advanced/scheduling/)). ## Hooks as attributes and events [Section titled “Hooks as attributes and events”](#hooks-as-attributes-and-events) Hook attributes are found by discovery, not registration. `ModuleServiceProvider` scans `app/` with `spatie/php-structure-discoverer`, and `src/Hook/Infrastructure/Services/HookDiscovery.php` collects public methods carrying `#[Action]` or `#[Filter]`, then registers them with an instance resolved from the container: src/Hook/Infrastructure/Services/HookDiscovery.php ```php $action = $hookAttribute->newInstance(); $instance = $this->getInstanceFromPool($className); $this->actionService->add( hooks: $action->hook, callback: [$instance, $methodName], priority: $action->priority ); ``` In the other direction, `src/Events/WordPress/` turns selected WordPress actions into Laravel events. Each dispatcher subscribes to a list of hooks and maps them to event classes. `PostEventDispatcher`, for example, reads `transition_post_status` and dispatches `PostCreated`, `PostPublished`, `PostTrashed`, `PostRestored` or `PostUpdated`. See [actions and filters](/hooks/actions-filters/) and [events and listeners](/hooks/events-listeners/). ## Performance in the code [Section titled “Performance in the code”](#performance-in-the-code) * **Discovery cache.** Discovered structures are cached through Laravel’s cache under a `pollora` prefix, except in debug mode, where a null driver forces a fresh scan (`src/Discovery/Infrastructure/Services/DiscoveryCacheManager.php`). In debug mode a slow scan is logged. * **Template lookups** are memoised per request (`FileSystemTemplateFinder::$locateCache`). Blade page templates, found by scanning views for a `{{-- Template Name: ... --}}` comment, are cached with `wp_cache_set()`. * **`/api/` requests** skip plugins by default, as shown in step 3. * **HTTP caching** for anonymous visitors is on by default (step 7). ## Trade-offs [Section titled “Trade-offs”](#trade-offs) * **Every web request boots both frameworks.** Front-end pages run Laravel and a full `wp-settings.php`, plugins included. Admin requests boot Laravel as well, through `wp-config.php`. The `/api/` mode is the only built-in way to load less. * **WordPress core is patched.** WordPress and Laravel both declare a global `__()`. Pollora applies a Composer patch that renames WordPress’s to `__wp()` (`patches/wordpress-core.patch`), and `pollora/helper-overrider` provides one `__()` that serves both translation systems. The patch is applied by `cweagans/composer-patches`, and `pollora:doctor` checks that it is present. * **Core updates go through Composer.** `WP_AUTO_UPDATE_CORE` is false. * **WP-Cron needs a decision.** It is off by default, so scheduled events need a real cron or the Laravel scheduler mode. * **Front-end rendering is Laravel’s.** Plugins that depend on `template-loader.php` including a PHP file see the same `template_include` filter, but the result is rendered as a view. Block themes still work: their `template-canvas.php` is included by `FrontendController` when no Blade view exists. If you want the opposite arrangement, with WordPress in charge and Laravel components added to it, look at Acorn. [How Pollora compares](/compare/) sets the options side by side, and [Why Pollora](/why/) explains the reasons for this design. To try it, start with the [installation guide](/getting-started/installation/) and the [WordPress configuration reference](/core-concepts/wordpress-config/). # Laravel and WordPress: every way to combine them (2026) > Five ways to use Laravel with WordPress: headless, Corcel, Acorn and Sage, Pollora, or two apps. What each keeps, what it costs, and when to pick it. Teams reach for Laravel and WordPress together for a common reason: editors want the WordPress admin, developers want Laravel’s routing, Blade, Eloquent, queues and tests. There are several ways to combine them, and they differ in one main question: which of the two is in charge of the request. This guide covers five approaches, what each keeps and loses, and when it is the right choice. Pollora, which this site documents, is one of them. It is not the best answer for every project. ## 1. Headless WordPress with a Laravel front end [Section titled “1. Headless WordPress with a Laravel front end”](#1-headless-wordpress-with-a-laravel-front-end) **How it works.** WordPress runs on its own and only manages content. The Laravel application fetches that content over HTTP and renders every public page. The data comes from the [WordPress REST API](https://developer.wordpress.org/rest-api/), which is part of core and returns JSON, or from [WPGraphQL](https://www.wpgraphql.com/), a free, open-source plugin that adds a GraphQL API. **What you keep.** The full admin and block editor for editors. Plugins that work on data and in the admin (custom fields, workflows, user management) keep working. Laravel owns the front end completely, and the same API can feed other clients, such as a mobile app. **What you lose or rebuild.** * Anything a plugin prints through the theme (head tags, front-end scripts, forms, widgets) does not reach your pages. You rebuild it or fetch it. Some plugins help: Yoast SEO adds `yoast_head` and `yoast_head_json` fields to REST responses and a `/wp-json/yoast/v1/get_head` endpoint for headless sites. * Previews. The editor’s preview button points to the WordPress front end. Drafts and private content are only available through the API with authentication, so a preview flow in Laravel is something you build. * Caching runs on two layers: WordPress responses and Laravel pages. When content changes, your Laravel cache needs to know. For GraphQL, WPGraphQL offers a Smart Cache extension for this. **Complexity.** High. Two applications, two deployments, and an API contract between them. **Pick it when** the front end is a product in its own right, several clients consume the same content, or separate teams own the CMS and the front end. ## 2. Corcel: Laravel reads the WordPress database [Section titled “2. Corcel: Laravel reads the WordPress database”](#2-corcel-laravel-reads-the-wordpress-database) **How it works.** [Corcel](https://github.com/corcel/corcel) is “a collection of Model classes that allows you to get data directly from a WordPress database”. You add a database connection to the WordPress tables, and query posts, pages, custom post types, meta, taxonomies, menus, users and options as Eloquent models. ACF fields are available through the separate `corcel/acf` package, and Corcel can authenticate Laravel users against WordPress users. WordPress itself still runs separately for the admin. **What you keep.** Fast, direct reads with Eloquent, no HTTP round trip, and a familiar Laravel codebase. **What you lose.** WordPress does not run when Laravel reads the data, so no hooks fire: filters that plugins apply to content, plugin front-end output and SEO plugin tags do not happen unless you reproduce them. Corcel has its own shortcode support, which you configure. Writing through Corcel also bypasses WordPress hooks, so plugins that react to a save do not see it. Previews are yours to build, as in the headless approach. Note that the version table in Corcel’s README stops at Laravel 12. **Complexity.** Low to start. It grows with every plugin behaviour you have to re-create. **Pick it when** a Laravel application needs to read WordPress content, the content model is simple, and you do not rely on plugins to shape the output. ## 3. Acorn, Sage and Radicle: Laravel components inside WordPress [Section titled “3. Acorn, Sage and Radicle: Laravel components inside WordPress”](#3-acorn-sage-and-radicle-laravel-components-inside-wordpress) **How it works.** WordPress stays in charge. [Acorn](https://roots.io/acorn/), from the Roots team, “provides a way to gracefully load a Laravel application container inside of WordPress while respecting the WordPress lifecycle and template hierarchy”. It is booted from a theme’s `functions.php` or a plugin, and brings Blade, service providers, Laravel-style routes, middleware, Eloquent, queues and Artisan commands through `wp acorn`. [Sage](https://github.com/roots/sage) is a starter theme built on it with Blade and Tailwind CSS. [Bedrock](https://github.com/roots/bedrock) is a Composer-based WordPress boilerplate. [Radicle](https://roots.io/radicle/) is a complete starter, sold as a one-time purchase ($80 for one site, $240 unlimited), that packages Acorn, Bedrock and Sage with a Laravel-style folder structure, Laravel routes, block scaffolding and a testing setup. **What you keep.** Everything WordPress does. The site is a WordPress site with a more capable theme, so plugins, the admin, previews, SEO plugins and caching plugins behave as they would on any WordPress install. The Roots stack is widely used, well documented and MIT-licensed. **What you trade.** By design, WordPress keeps the request lifecycle and Laravel works within it: you use the Laravel components Acorn provides, through Acorn’s entry points, rather than a full Laravel application that owns the request. For a WordPress-first project, that is exactly the point. **Complexity.** Low to moderate. A WordPress developer can adopt it one piece at a time, starting with Blade templates. **Pick it when** the project is a WordPress site first, the team knows WordPress, and you want Blade and some Laravel tooling without changing how the site is hosted and run. ## 4. Pollora: WordPress inside a Laravel application [Section titled “4. Pollora: WordPress inside a Laravel application”](#4-pollora-wordpress-inside-a-laravel-application) **How it works.** The order is reversed. Laravel boots first, and WordPress runs inside the Laravel application: its admin, database, plugins and REST API keep working. Public pages are rendered with Laravel. Routes in `routes/web.php` are matched first; `Route::wp()` binds WordPress conditional tags (`single`, `page`, `archive` and so on) to controllers; when nothing matches, the WordPress template hierarchy resolves a Blade view. Post types, taxonomies, hooks, REST routes and scheduled tasks are declared with PHP 8 attributes and discovered automatically. See [WordPress routes](/routing/wordpress-routes/) and [Actions and filters](/hooks/actions-filters/). **What you keep.** The admin, the editor and the plugins. WordPress still parses each request, so conditional tags such as `is_preview()` work, and `Route::wp()` has a `preview` condition. A theme’s Blade layout calls `wp_head()`, as WordPress requires of every theme, and that is where SEO plugins print their tags. On the Laravel side you get the whole framework: controllers, middleware, Eloquent, queues, Artisan, a WordPress authentication guard and WordPress hooks as Laravel events. **What you lose or take on.** * A non-standard layout. Pollora uses a Bedrock-style structure where the web server’s document root is `public/`, with WordPress core in `public/cms` and `wp-content` in `public/content`. Hosts built around a standard WordPress layout may not fit. See [Server configuration](/getting-started/server-configuration/). * A newer stack: a new project needs PHP 8.4, Laravel 13 and WordPress 7.1 or later. * Plugins that write their own drop-ins or assume WordPress’s default paths, such as some page-cache plugins, are worth testing before you rely on them. * The team needs to be comfortable with both Laravel and WordPress. **Complexity.** Moderate. One application and one deployment, but two frameworks to understand. **Pick it when** the project is as much an application as a website, with business logic, queues, APIs or tests, and editors still need WordPress. It is less suited to a brochure site that a WordPress theme handles well, or to hosting you cannot configure. ## 5. Two separate applications [Section titled “5. Two separate applications”](#5-two-separate-applications) **How it works.** WordPress runs the content site on one host (`www.example.com` or `blog.example.com`), and a Laravel application runs the product on another (`app.example.com`). Each has its own front end. Sometimes they share a database, or a login. **What you keep.** Each tool at full strength, with no integration layer. WordPress keeps every plugin, preview and caching option; Laravel is a plain Laravel application. **What you lose.** Shared parts are maintained twice: the design system, navigation, footer and analytics. A single sign-on between the two is your work. Sharing a database couples the Laravel code to WordPress’s schema, which is the same trade-off Corcel makes. **Complexity.** Low per application, higher for the team that keeps two codebases consistent. **Pick it when** the marketing site and the product are clearly separate, are owned by different people, and rarely need to show each other’s data. ## Decision table [Section titled “Decision table”](#decision-table) | | Headless | Corcel | Acorn / Sage / Radicle | Pollora | Two apps | | -------------------------------- | ----------------------------------------- | -------------------------------- | -------------------------- | ----------------------------------- | ---------------------------- | | **In charge of the request** | Laravel (WordPress via API) | Laravel (WordPress via database) | WordPress | Laravel, with WordPress inside | Each on its own host | | **wp-admin and editor** | Yes | Yes, separate install | Yes | Yes | Yes | | **Plugins affect the front end** | Only through the API | No | Yes | Yes, test page-cache plugins | Yes, on the WordPress site | | **Previews** | To build | To build | Native | Native WordPress query | Native on the WordPress site | | **SEO plugin output** | Via API fields, if the plugin offers them | To rebuild | Native | Through `wp_head()` | Native on the WordPress site | | **Full Laravel application** | Yes | Yes | Laravel components | Yes | Yes, separate | | **Deployments** | Two | One or two | One | One | Two | | **Hosting** | Any, twice | Any | Standard WordPress hosting | `public/` document root, PHP 8.4 | Any, twice | | **Main cost** | Rebuilding front-end features | Hooks never run | Laravel as a guest | Non-standard layout, two frameworks | Duplicated shared parts | A short version: * **The site is WordPress, and you want nicer templates:** Acorn and Sage. * **The front end is its own product or serves several clients:** headless. * **A Laravel app only needs to read some WordPress content:** Corcel. * **One application with business logic, and editors in WordPress:** Pollora. * **A marketing site and a separate product:** two applications. For a feature-by-feature comparison of Pollora with Acorn, Sage, Corcel and others, see [Compare](/compare/). For the reasoning behind Pollora’s design, see [Why Pollora](/why/). # WordPress hooks with PHP 8 attributes > Replace add_action and add_filter calls in functions.php with PHP 8 attributes: #[Action] and #[Filter] methods, auto-discovered and container-resolved. WordPress is extended through hooks: actions run code at a given moment, filters change a value before WordPress uses it. This guide shows how to declare hooks with PHP 8 attributes instead of `add_action()` and `add_filter()` calls, first as a general technique and then end to end with Pollora. ## The problem with scattered add\_action calls [Section titled “The problem with scattered add\_action calls”](#the-problem-with-scattered-add_action-calls) A typical theme or plugin registers hooks like this: functions.php ```php add_action('wp_head', 'mytheme_meta_description', 1); add_filter('body_class', 'mytheme_body_class', 10, 2); function mytheme_meta_description() { if (is_singular()) { echo ''; } } function mytheme_body_class($classes, $css_class) { $classes[] = 'has-js'; return $classes; } ``` This works, and it is how most WordPress code is written. It has a few costs as a project grows: * **Registration is separate from the code it registers.** The hook name, priority and argument count live on one line, the callback somewhere else, often in another file. * **The argument count is easy to get wrong.** `add_filter()` passes one argument unless you set the fourth parameter. Forget it and `$css_class` is silently missing. * **Everything is global.** Callbacks are prefixed functions or static methods, which makes it awkward to give them dependencies or to test them. * **`functions.php` becomes a registry.** Files are included by hand, and finding what listens to `wp_head` means searching the whole codebase. Wrapping hooks in classes helps, but you still call `add_action()` in a constructor or an `init()` method, and something still has to instantiate that class. ## What PHP 8 attributes are [Section titled “What PHP 8 attributes are”](#what-php-8-attributes-are) Attributes, added in PHP 8.0, are structured metadata attached to classes, methods, properties or parameters: ```php #[Action('wp_head', priority: 1)] public function metaDescription(): void {} ``` On their own they do nothing. A framework reads them with the Reflection API (`ReflectionMethod::getAttributes()`) and acts on them. For hooks, that means a method can carry its own hook name and priority, and a scanner can register it. The declaration and the code sit together, and the argument count can be read from the method signature. ## Declaring actions and filters in Pollora [Section titled “Declaring actions and filters in Pollora”](#declaring-actions-and-filters-in-pollora) Pollora provides two attributes, `Pollora\Attributes\Action` and `Pollora\Attributes\Filter`. Both take the hook name and an optional `priority` (default `10`). Both can target methods only, and both are repeatable, so one method can listen to several hooks. Here is the example above as a Pollora class: app/Cms/Hooks/Seo.php ```php '; } } #[Filter('body_class')] public function bodyClass(array $classes, array $cssClass): array { $classes[] = 'has-js'; return $classes; } } ``` There is no argument count to declare. When Pollora registers the method, it counts the parameters in its signature and passes that number to WordPress as `$accepted_args`. `bodyClass()` declares two parameters, so it receives both values `body_class` provides. If you only need the first one, declare only the first one. A method that should run on several hooks takes several attributes: app/Cms/Hooks/Setup.php ```php #[Action('init', priority: 10)] #[Action('wp_loaded', priority: 15)] public function setup(): void { // Runs on both hooks } ``` You can generate a hook class with Artisan: ```bash php artisan pollora:make:action Seo php artisan pollora:make:filter ContentFilters ``` ## Where the classes live and how they are found [Section titled “Where the classes live and how they are found”](#where-the-classes-live-and-how-they-are-found) You do not register these classes anywhere. Pollora’s discovery system scans your application’s `app/` directory (as well as active themes and modules) for public methods carrying `#[Action]` or `#[Filter]`, and registers each one with WordPress. Abstract classes are skipped. `app/Cms/Hooks` is the convention, and it is where the generators write, but any class under `app/` is found. Group hooks by feature (`Seo`, `Media`, `Admin`) rather than by hook name. To keep a class out of discovery, add `#[SkipDiscovery]` from `Pollora\Attributes\SkipDiscovery`. See [Auto-discovery](/core-concepts/auto-discovery/) for path exclusions and custom discoveries. ## Dependency injection in hook classes [Section titled “Dependency injection in hook classes”](#dependency-injection-in-hook-classes) Discovered hook classes are built through the Laravel service container, so constructor injection works the same way it does in a controller. One instance is created per class and shared by all the hook methods it declares. app/Cms/Hooks/Analytics.php ```php config->get('services.analytics.id'); if ($id) { echo view('partials.analytics', ['id' => $id])->render(); } } } ``` Keep constructors light and do WordPress work inside the hook methods, where you know which hook is running. If a service needs to add hooks itself, inject the hook contracts `Pollora\Hook\Domain\Contract\Action` and `Pollora\Hook\Domain\Contract\Filter`. They are documented as stable public contracts. See [Service access](/hooks/actions-filters/#service-access). ## The facade alternative [Section titled “The facade alternative”](#the-facade-alternative) Attributes suit hooks that are always on. When registration depends on runtime conditions, use the `Action` and `Filter` facades, for example in a service provider: app/Providers/AppServiceProvider.php ```php use App\Cms\Hooks\ContentHandler; use Pollora\Support\Facades\Action; use Pollora\Support\Facades\Filter; public function boot(): void { // Resolves ContentHandler from the container and calls its theContent() method Filter::add('the_content', ContentHandler::class, 20); if (config('app.debug')) { Action::add('wp_footer', function (): void { echo ''; }); } } ``` `add()` accepts a hook name or an array of names, a callback, a priority and an optional argument count (detected from the callback when omitted). When you pass a class name alone, Pollora builds it through the container and calls the method named after the hook in camel case: `theContent()` for `the_content`. `Action::do()` and `Filter::apply()` fire hooks, `exists()` and `remove()` behave like their WordPress counterparts, and `callbacks()` returns what was registered. Like `add_action()`, the facades accept a callback that is not defined yet, such as a function from a plugin that loads later; it is checked when the hook fires. The [Actions & Filters](/hooks/actions-filters/) page covers the full API. ## Checking what was discovered [Section titled “Checking what was discovered”](#checking-what-was-discovered) Discovery results are cached. Two Artisan commands help when a hook does not fire: ```bash # Run discovery and print how many items each discovery found ("hooks", "post_types", ...) php artisan discovery:run # Inspect only hook discovery php artisan discovery:run --discovery=hooks # Clear the discovery cache after moving or renaming classes php artisan discovery:clear ``` If the count for `hooks` does not change after you add a method, check that the method is public, the class is not abstract, and the attribute is imported from `Pollora\Attributes`. Also check that the class can be built by the container: if it cannot (for example, a constructor dependency that does not resolve), that class’s hooks are skipped without stopping the request. ## WordPress hooks as Laravel events [Section titled “WordPress hooks as Laravel events”](#wordpress-hooks-as-laravel-events) For common WordPress events, Pollora also dispatches typed Laravel events, so you can handle them with ordinary listeners, including queued ones: app/Listeners/NotifySubscribers.php ```php post; // WP_Post // ... } } ``` Use attributes when you need to change WordPress output or data (a filter must return a value). Use events when you react to something that happened, especially for slow work that belongs in a queue. The list of events is in [Events & Listeners](/hooks/events-listeners/). ## Next steps [Section titled “Next steps”](#next-steps) * [Actions & Filters](/hooks/actions-filters/): full attribute and facade reference * [Events & Listeners](/hooks/events-listeners/): WordPress hooks as Laravel events * [Auto-discovery](/core-concepts/auto-discovery/): how classes are scanned, cached and excluded * [Custom post types and taxonomies with PHP attributes](/guides/custom-post-types-php-attributes/) * [Installation](/getting-started/installation/): start a Pollora project * [Pollora compared with Acorn, Sage, Radicle and Corcel](/compare/) # Abilities > Declare WordPress abilities in Pollora with an attribute or a facade, with permissions and input schemas, so AI agents and MCP clients can use your site. WordPress 6.9 introduced the **Abilities API**: a registry where plugins, themes and core declare what they can do in a machine-readable form — inputs, outputs, permissions, behaviour — so that AI agents and automation tools can discover and invoke site functionality without a bespoke integration for each one. Pollora wraps it in the [`pollora/abilities`](https://github.com/Pollora/abilities) package, with two ways to declare an ability: the **imperative `Ability` facade** and the **declarative `#[Ability]` attribute**. Permission checks default to *refusing*, so an ability that forgets to declare one is inert rather than open. > **Requires WordPress 6.9 or later.** On an older install the declarations are accepted and simply never published — there is nowhere to put them. ## Abilities and MCP [Section titled “Abilities and MCP”](#abilities-and-mcp) An ability is not an MCP tool, though it is what an MCP tool is usually made of. The [MCP Adapter](https://github.com/WordPress/mcp-adapter) plugin publishes registered abilities over the Model Context Protocol; the core abilities REST controllers expose them at `/wp-json/wp-abilities/v1/abilities`. Neither is Pollora’s concern: you declare the ability once, and whatever consumes abilities picks it up. That is also why the package is named for abilities rather than for MCP — it implements no part of that protocol. ## Categories [Section titled “Categories”](#categories) Every ability is filed under a category, and the category has to exist first. Declare it once, typically in a service provider: ```php use Pollora\Support\Facades\Ability; Ability::category('acme-content', 'Editorial', 'Posts and pages.'); ``` Category slugs are **global to the install** and WordPress core already claims several of them, so prefix yours with something the project owns. A collision is not benign: the second registration is refused and every ability pointing at the category fails with it. The label falls back to a title-cased slug, and the description to one derived from the label. Both fallbacks exist because WordPress rejects a category with a blank description by returning `null` rather than raising — an empty one would vanish without a word. ## Imperative API (Facade) [Section titled “Imperative API (Facade)”](#imperative-api-facade) ```php use Pollora\Abilities\Domain\Model\Input; use Pollora\Abilities\Domain\Schema\SchemaBuilder; use Pollora\Support\Facades\Ability; Ability::define('acme/get-posts') ->description('Returns the most recent posts, newest first.') ->category('acme-content') ->input(fn (SchemaBuilder $schema) => $schema ->integer('limit', 'How many posts to return.', default: 10, minimum: 1, maximum: 100)) ->can(fn (Input $input): bool => current_user_can('edit_posts')) ->using(fn (Input $input): array => array_map( static fn (WP_Post $post): array => ['id' => $post->ID, 'title' => $post->post_title], get_posts(['numberposts' => $input->integer('limit', 10)]), )); ``` Nothing is registered until a body is supplied through `using()` or `handledBy()`, so an abandoned chain declares nothing rather than something broken. Ability names are `namespace/slug`, both parts lowercase alphanumerics separated by single dashes. A bare slug registers nothing and WordPress reports it through `_doing_it_wrong()`, where it is easy to miss — so Pollora refuses it at declaration time instead, along with an empty label, an empty description, or an input schema that is not an object. ### When declarations are published [Section titled “When declarations are published”](#when-declarations-are-published) Declaration and registration are two phases. You declare wherever it is natural to write it — a service provider, a discovered class, a theme bootstrap — which is almost always before WordPress has initialised its abilities registry. Pollora queues the declaration and flushes it on `wp_abilities_api_categories_init` and then `wp_abilities_api_init`, the only moments WordPress accepts them. You never hook those yourself; the framework does. It does mean an ability declared *after* something has already touched the registry is too late for that request. ## Declarative API (Attribute) [Section titled “Declarative API (Attribute)”](#declarative-api-attribute) For anything past a couple of lines, write a class. Everything the ability needs sits in one place with a typed signature, which makes it straightforward to unit-test without registering anything: ```php use Pollora\Abilities\Domain\Contracts\AbilityHandler; use Pollora\Abilities\Domain\Model\Behaviour; use Pollora\Abilities\Domain\Model\Input; use Pollora\Abilities\Domain\Schema\SchemaBuilder; use Pollora\Attributes\Ability; #[Ability( name: 'acme/create-post', description: 'Creates a post from a title and a status.', category: 'acme-content', behaviour: Behaviour::Creates, )] final class CreatePost implements AbilityHandler { public function schema(SchemaBuilder $schema): void { $schema->string('title', 'Title of the post to create.', required: true); $schema->enum('status', 'Publication status.', ['draft', 'publish'], default: 'draft'); } public function authorize(Input $input): mixed { return current_user_can('edit_posts') ?: new WP_Error('forbidden', 'You cannot create posts.', ['status' => 403]); } public function handle(Input $input): mixed { return ['id' => wp_insert_post([ 'post_title' => $input->string('title'), 'post_status' => $input->string('status', 'draft'), ])]; } } ``` The class is discovered anywhere the discovery system scans — `app/`, a theme, a module — and instantiated through the service container, so constructor injection works as expected. The attribute carries what the ability *is*; the handler carries what it *does*. If the named category has not been declared, Pollora declares it for you rather than letting the ability disappear; an explicit `Ability::category()` still wins, whichever ran first. ### Attribute Parameters [Section titled “Attribute Parameters”](#attribute-parameters) | Parameter | Type | Default | Description | | ------------- | ----------- | -------------------- | ---------------------------------------------------- | | `name` | `string` | *(required)* | Fully-qualified ability name, `namespace/slug` | | `description` | `string` | *(required)* | What the ability does — this is the tool description | | `category` | `string` | *(required)* | Slug of the category the ability is filed under | | `label` | `string` | *(title-cased slug)* | Short human-readable title | | `behaviour` | `Behaviour` | `Behaviour::Reads` | What the ability does to the site | A class carrying `#[Ability]` without implementing `AbilityHandler` is logged as an error rather than skipped: the attribute is an explicit statement of intent, so failing to honour it is worth surfacing. ## Behaviour [Section titled “Behaviour”](#behaviour) Every ability declares what it does to the site. WordPress publishes this under `meta.annotations`, and consumers turn it into the `readOnlyHint`, `destructiveHint` and `idempotentHint` tool annotations a client uses to decide how much ceremony an invocation deserves — a read may run unattended, a delete should be confirmed with the user first. | Facade | Attribute | `readonly` | `destructive` | `idempotent` | Means | | ----------------------- | -------------------- | ---------- | ------------- | ------------ | ------------------------------------------------------- | | `->reads()` *(default)* | `Behaviour::Reads` | ✓ | | ✓ | Changes nothing | | `->creates()` | `Behaviour::Creates` | | | | Adds something on every call — two calls, two records | | `->updates()` | `Behaviour::Updates` | | ✓ | ✓ | Overwrites part of a record; the previous value is gone | | `->deletes()` | `Behaviour::Deletes` | | ✓ | ✓ | Removes a record | Getting these wrong is worse than omitting them, which is why they are declared as one of four shapes rather than three loose booleans. > **They are advisory.** WordPress does not enforce them. The permission callback is what protects the site. ## Permissions [Section titled “Permissions”](#permissions) The permission check receives the **same input** as the body, which is what allows per-object checks — `edit_post` on the identifier being edited, rather than a blanket `edit_posts`: ```php Ability::define('acme/update-post') ->description('Updates the title of an existing post.') ->category('acme-content') ->updates() ->input(fn (SchemaBuilder $schema) => $schema ->integer('id', 'Identifier of the post to update.', required: true) ->string('title', 'The new title.', required: true)) ->can(fn (Input $input): bool => current_user_can('edit_post', $input->id('id'))) ->using(fn (Input $input): array => …); ``` Return a `WP_Error` instead of `false` to explain the refusal. A client that knows *why* it was refused can act on it, where a bare denial leaves the model guessing. Omitting `can()` entirely means the ability refuses everything. That is deliberate: a forgotten check should make an ability useless, not public. ## Input [Section titled “Input”](#input) `Input` is a defensive reader over what the ability was handed. WordPress validates against the declared schema before the body runs, but it does not guarantee a shape — an ability whose every property is optional can legitimately be invoked with `null`. ```php $input->string('title'); // '' when absent $input->integer('limit', default: 10, max: 100); // coerces "12", clamps 100000 → 100 $input->float('score', min: 0.0, max: 1.0); $input->id('post_id'); // 0 when absent or invalid, never negative $input->boolean('draft'); // accepts true, "true", "1", "yes", "on" $input->stringList('tags'); // trims, drops blanks and non-scalars $input->idList('post_ids'); $input->map('terms'); // free-form associative array $input->all(); // everything, for the rare case ``` Accessors coerce rather than throw. A model that sends `"12"` where an integer was asked for should get a working call, not an error it cannot act on. `has()` and `filled()` are distinct on purpose: | Method | `''` | `[]` | `0` | absent | `null` | | ---------- | ---- | ---- | --- | ------ | ------ | | `has()` | ✓ | ✓ | ✓ | | | | `filled()` | | | ✓ | | | Use `filled()` by default — callers routinely send empty strings for properties they mean to leave alone, and treating those as present produces empty search terms and cleared taxonomies. Use `has()` where “explicitly zero” is a different instruction from “not mentioned”, such as a menu order or a parent identifier. ## Schema [Section titled “Schema”](#schema) The input schema is the only documentation a language model gets about your ability, so descriptions are not decoration — they *are* the interface. Every `SchemaBuilder` method takes one, and there is no overload that omits it. ```php $schema ->string('title', 'Title of the post.', required: true) ->string('url', 'Source URL.', format: 'uri') ->enum('status', 'Publication status.', ['draft', 'publish'], default: 'draft') ->integer('limit', 'How many to return.', default: 10, minimum: 1, maximum: 100) ->number('score', 'Relevance threshold.', minimum: 0.0, maximum: 1.0) ->boolean('sticky', 'Whether to pin the post.') ->list('tags', 'Tag slugs to attach.') ->map('terms', 'Taxonomy slug to term slugs.', ['type' => 'array']) ->object('author', 'The post author.', fn (SchemaBuilder $author) => $author ->integer('id', 'User identifier.', required: true)) ->raw('id', ['oneOf' => [['type' => 'string'], ['type' => 'integer']]]); ``` Two habits worth keeping: * **Prefer `enum()` over a free string** wherever the accepted values are known. A model that can see the options picks one; a model given a free string invents a plausible value that fails downstream. * **Set `maximum` on anything that sizes a query**, so a model cannot ask for every row in the table. WordPress validates input against this schema before your body runs, and returns a `WP_Error` naming the offending property when it does not match — you do not have to check for missing required values yourself. ### Describing the output [Section titled “Describing the output”](#describing-the-output) `output()` is optional and takes the same builder. Declare it where the returned shape is stable: it lets a client validate what it got instead of trusting it. ```php Ability::define('acme/get-post') ->description('Returns a single post by identifier.') ->category('acme-content') ->output(fn (SchemaBuilder $schema) => $schema ->integer('id', 'The post identifier.') ->string('title', 'The post title.')) // … ``` ## Testing an ability [Section titled “Testing an ability”](#testing-an-ability) Registered abilities are exposed through the core REST controllers, so you can exercise one without an MCP client: ```bash wp eval '$a = wp_get_ability("acme/get-posts"); var_dump($a->execute(["limit" => 3]));' ``` ```plaintext GET /wp-json/wp-abilities/v1/abilities GET /wp-json/wp-abilities/v1/abilities/acme/get-posts POST /wp-json/wp-abilities/v1/abilities/acme/get-posts/run ``` A handler class needs no WordPress at all to unit-test — call `schema()`, `authorize()` and `handle()` directly: ```php it('creates a draft post', function (): void { $result = (new CreatePost)->handle(Input::wrap(['title' => 'Hello'])); expect($result['id'])->toBeInt(); }); ``` ## Package API [Section titled “Package API”](#package-api) The framework wires the package up; you normally only touch the facade and the attribute. The pieces underneath, for the rare case that needs them: | Class | Purpose | | -------------------------------------------------------------- | ------------------------------------------------------------------- | | `Pollora\Abilities\Factory\AbilityFactory` | Bound as `wp.abilities`, what the facade resolves | | `Pollora\Abilities\Application\Service\RegisterAbilityService` | Holds the declaration queues; `registered()` reports what went live | | `Pollora\Abilities\Port\Out\AbilityRegistrarPort` | Swap to publish declarations somewhere other than WordPress | | `Pollora\Abilities\Domain\Model\Ability` | The immutable declaration itself | `RegisterAbilityService::registered()` is worth knowing about: it returns the exact list of ability names published this request, which is what a downstream consumer — an MCP server declaration, a settings screen — needs without rediscovering it. ## Related [Section titled “Related”](#related) * [Discovery](/core-concepts/auto-discovery/) — how `#[Ability]` classes are found * [WP REST API](/advanced/rest-api/) — `#[WpRestRoute]` for hand-written endpoints * [Nectar — AI Context](/nectar/overview/) — AI guidelines and agent skills for development # AJAX > Handle WordPress AJAX requests in Pollora with an attribute or a facade, understand the security model, and call your handlers from frontend JavaScript. Pollora provides two ways to register WordPress AJAX handlers: the **imperative `Ajax` facade** and the **declarative `#[Ajax]` attribute**. Both default to logged-in users only (security-by-default). ## Imperative API (Facade) [Section titled “Imperative API (Facade)”](#imperative-api-facade) Use the `listen()` method to register an AJAX handler inline: ```php use Pollora\Support\Facades\Ajax; Ajax::listen('my_action', function () { wp_send_json_success(['message' => 'It works!']); }); ``` By default, this registers `wp_ajax_my_action` **only** — the handler is restricted to **logged-in users**. Unauthenticated users cannot reach it. ### Targeting Users [Section titled “Targeting Users”](#targeting-users) ```php // Default — logged-in users only (wp_ajax_*) Ajax::listen('my_action', function () { wp_send_json_success(['user' => wp_get_current_user()->display_name]); }); // Explicit — all users, logged-in AND guests (wp_ajax_* + wp_ajax_nopriv_*) Ajax::listen('public_action', function () { wp_send_json_success(['message' => 'Hello everyone!']); })->forAllUsers(); // Guest users only (wp_ajax_nopriv_*) Ajax::listen('guest_action', function () { wp_send_json_success(['message' => 'Hello guest!']); })->forGuestUsers(); ``` ### Using a Controller Method [Section titled “Using a Controller Method”](#using-a-controller-method) ```php Ajax::listen('load_more_posts', [PostController::class, 'loadMore']); ``` ## Declarative API (Attribute) [Section titled “Declarative API (Attribute)”](#declarative-api-attribute) Place the `#[Ajax]` attribute on a public method to auto-register it as an AJAX handler via the discovery system — no manual registration needed. ```php use Pollora\Attributes\Ajax; use Pollora\Ajax\Domain\Model\AjaxAccess; class NewsletterHandler { // Logged-in users only (default) #[Ajax('subscribe')] public function subscribe(): void { wp_send_json_success(['message' => 'Subscribed!']); } // All users (explicit opt-in) #[Ajax('load_more', access: AjaxAccess::ALL)] public function loadMore(): void { wp_send_json_success([/* ... */]); } // Guest users only #[Ajax('track_visit', access: AjaxAccess::GUEST)] public function trackVisit(): void { wp_send_json_success([/* ... */]); } } ``` The class is automatically discovered and instantiated via the service container, so constructor injection works as expected. ### Attribute Parameters [Section titled “Attribute Parameters”](#attribute-parameters) | Parameter | Type | Default | Description | | --------- | ------------ | -------------------- | ------------------------------ | | `action` | `string` | *(required)* | The WordPress AJAX action name | | `access` | `AjaxAccess` | `AjaxAccess::LOGGED` | Audience targeting | ### AjaxAccess Enum [Section titled “AjaxAccess Enum”](#ajaxaccess-enum) | Value | WordPress Hook | Audience | | -------------------- | ---------------------------------------------- | ------------------------------ | | `AjaxAccess::LOGGED` | `wp_ajax_{action}` | Logged-in users only (default) | | `AjaxAccess::ALL` | `wp_ajax_{action}` + `wp_ajax_nopriv_{action}` | Everyone | | `AjaxAccess::GUEST` | `wp_ajax_nopriv_{action}` | Guests only | ## Security Model [Section titled “Security Model”](#security-model) > **Why secure-by-default?** Exposing `wp_ajax_nopriv_*` allows any unauthenticated visitor to call the endpoint. This should be a conscious decision, not an implicit default. Both the facade and attribute API require explicit opt-in to expose an endpoint publicly. ## Frontend JavaScript [Section titled “Frontend JavaScript”](#frontend-javascript) Send AJAX requests from JavaScript using the `ajaxurl` global provided by WordPress: ```javascript jQuery.post(ajaxurl, { action: 'my_action', _wpnonce: myApp.nonce, data: 'some data' }, function (response) { if (response.success) { console.log(response.data); } }); ``` Make sure to localize your script with the nonce: ```php use Pollora\Support\Facades\Asset; Asset::add('my-script', 'assets/js/app.js') ->toFrontend() ->localize('myApp', [ 'nonce' => wp_create_nonce('wp_rest'), ]); ``` # Authentication > Use Laravel's Auth facade with WordPress users in Pollora: the WordPress guard lets the standard Auth methods work against WordPress accounts. The `Auth` facade in Laravel allows you to manage user authentication in your application, with adaptations for WordPress integration. By implementing the `WordPressGuard`, you can handle authentication using the WordPress schema, while still leveraging Laravel’s standard features. ## User Authentication [Section titled “User Authentication”](#user-authentication) You can use the standard methods of the `Auth` facade to manage user authentication in your Laravel application, with adjustments for the WordPress schema. ### Checking Authentication [Section titled “Checking Authentication”](#checking-authentication) ```php if (Auth::check()) { // User is authenticated } else { // User is not authenticated } ``` The `check()` method verifies if the user is currently authenticated via WordPress. It uses the `check()` method you’ve implemented in the `WordPressGuard` class. ### Retrieving the Authenticated User [Section titled “Retrieving the Authenticated User”](#retrieving-the-authenticated-user) ```php $user = Auth::user(); if ($user) { // Authenticated user echo "Hello, " . $user->display_name; } else { // No authenticated user } ``` The `user()` method returns the currently authenticated user via WordPress. If no user is authenticated, it returns `null`. This uses the `user()` method you’ve set up in the `WordPressGuard` class. ### Attempting Authentication via WordPress [Section titled “Attempting Authentication via WordPress”](#attempting-authentication-via-wordpress) ```php if (Auth::attempt(['username' => $username, 'password' => $password, 'remember' => true])) { // User authenticated successfully } else { // Authentication failed } ``` The `attempt()` method tries to authenticate a user using the provided credentials. In this case, the credentials are verified by WordPress, and if successful, the user is automatically authenticated via Laravel. This method uses the `attempt()` method you’ve implemented in the `WordPressGuard` class. ### Temporary Authentication with `once()` [Section titled “Temporary Authentication with once()”](#temporary-authentication-with-once) ```php if (Auth::once(['username' => $username, 'password' => $password])) { // User temporarily authenticated successfully } else { // Temporary authentication failed } ``` The `once()` method works similarly to `attempt()`, but it temporarily authenticates the user only for the duration of the current request. After the request ends, the user won’t remain authenticated. This uses the `once()` method in the `WordPressGuard` class. ### Logging in Manually with `login()` [Section titled “Logging in Manually with login()”](#logging-in-manually-with-login) ```php $user = Auth::user(); if ($user) { Auth::login($user); // User manually logged in successfully } else { // No authenticated user } ``` The `login()` method allows you to manually authenticate a specific user. You need to pass an instance of a Laravel user (corresponding to a WordPress user) as an argument. This uses the `login()` method in the `WordPressGuard` class. ### Logging in via ID with `loginUsingId()` [Section titled “Logging in via ID with loginUsingId()”](#logging-in-via-id-with-loginusingid) ```php if (Auth::loginUsingId($userId)) { // User authenticated successfully via ID } else { // Authentication failed via ID } ``` The `loginUsingId()` method lets you authenticate a user using their ID. It uses the ID to find and authenticate the user, connecting them. This method uses the `loginUsingId()` method you’ve implemented in the `WordPressGuard` class. ### Temporary Login via ID with `onceUsingId()` [Section titled “Temporary Login via ID with onceUsingId()”](#temporary-login-via-id-with-onceusingid) ```php if (Auth::onceUsingId($userId)) { // User temporarily authenticated successfully via ID } else { // Temporary authentication failed via ID } ``` The `onceUsingId()` method is similar to `loginUsingId()`, but it temporarily authenticates the user only for the duration of the current request. This uses the `onceUsingId()` method you’ve implemented in the `WordPressGuard` class. ## Authorization [Section titled “Authorization”](#authorization) What an authenticated user may do — `$user->can('edit_posts')`, the `can:` middleware, `@can` in Blade, roles declared in code — is covered in [Roles & Capabilities](/advanced/roles-capabilities/). # Dashboard & Status > Check a Pollora installation from the WordPress admin dashboard or the CLI, see discovered components and diagnose a project with pollora:doctor. Pollora includes a built-in admin dashboard and an Artisan CLI command that give you a complete overview of your framework installation, discovered entities, and system health. ## Admin Dashboard [Section titled “Admin Dashboard”](#admin-dashboard) The dashboard is accessible in the WordPress admin under **Tools > Pollora**. It displays a branded overview of your project with the following cards: ### Information displayed [Section titled “Information displayed”](#information-displayed) | Card | Details | | ----------------------------- | --------------------------------------------------------------------- | | **Environment** | PHP, Laravel, and WordPress versions | | **WordPress Config** | WP\_DEBUG status, multisite, permalink structure | | **Post Types** | Discovered post types with labels, slugs, and class names | | **Taxonomies** | Discovered taxonomies with labels, slugs, and class names | | **Hooks** | Total count of discovered actions and filters | | **REST API Routes** | Classes discovered via `#[WpRestRoute]` | | **WP-CLI Commands** | Classes discovered via `#[WpCli]` | | **Scheduled Tasks** | Methods discovered via `#[Schedule]` | | **Auto-discovered Providers** | Service providers found by the discovery engine | | **Modules** | Laravel modules status (enabled/disabled) via nwidart/laravel-modules | | **Discovery Cache** | Cache driver and enabled status | | **Discovery Performance** | Cache hits/misses, classes processed, instance pool size | | **Active Theme** | Theme name, version, and template directory | ### Update notification [Section titled “Update notification”](#update-notification) When a newer stable version of Pollora is available on Packagist, a notification badge appears on the **Tools > Pollora** menu item, similar to WordPress’s Site Health counter. Dev versions (`dev-develop`, `dev-main`, etc.) are excluded from this check to avoid false positives. ### Access control [Section titled “Access control”](#access-control) The dashboard page requires the `manage_options` capability (administrators only). ## CLI Command [Section titled “CLI Command”](#cli-command) The `pollora:status` Artisan command provides the same information in the terminal: ```bash php artisan pollora:status ``` Example output: ```plaintext Pollora v13.35.5 (latest: v13.35.5) ✓ PHP 8.4.12 | Laravel 13.35 | WordPress 7.1 WP_DEBUG: off | Multisite: no | Permalinks: /%postname%/ Post Types: 2 registered (via discovery) · Projects [project] — App\Cms\PostTypes\Project · Services [service] — App\Cms\PostTypes\Service Taxonomies: 1 registered · Project Categories [project-category] — App\Cms\Taxonomies\ProjectCategory Hooks: 4 registered (2 actions, 2 filters) REST API routes: 1 registered · App\Cms\Rest\ProjectController WP-CLI commands: 1 registered · App\Cms\Cli\SeedCommand Scheduled tasks: 2 registered · App\Cms\Schedule\CacheCleanup::cleanExpiredTransients() · App\Cms\Schedule\CacheCleanup::cleanRevisions() Auto-discovered providers: 1 · App\Providers\AppServiceProvider Modules: 0 total (0 enabled, 0 disabled) Discovery cache: enabled (LaravelDiscoverCacheDriver) Discovery stats: 3 cache hits, 0 misses, 24 classes Theme: Starter Theme v1.0.0 (pollora-starter) ``` ### JSON output [Section titled “JSON output”](#json-output) Use the `--json` flag for machine-readable output, useful for AI agents, CI pipelines, or monitoring tools: ```bash php artisan pollora:status --json ``` This outputs the complete system information as a JSON object: ```json { "framework": { "current": "13.35.5", "latest": "13.35.5", "update_available": false, "development": false }, "environment": { "php": "8.4.12", "laravel": "13.35.0", "wordpress": "7.1" }, "wordpress": { "debug": false, "multisite": false, "permalink_structure": "/%postname%/" }, "discovery": { "post_types": { "count": 2, "items": ["..."] }, "taxonomies": { "count": 1, "items": ["..."] }, "hooks": { "count": 4, "actions": 2, "filters": 2 }, "rest_routes": { "count": 1, "items": ["..."] }, "wp_cli_commands": { "count": 1, "items": ["..."] }, "schedules": { "count": 2, "items": ["..."] }, "service_providers": { "count": 1, "items": ["..."] } }, "performance": { "..." }, "cache": { "driver": "LaravelDiscoverCacheDriver", "enabled": true }, "modules": { "count": 0, "enabled": 0, "disabled": 0, "items": [] }, "theme": { "name": "Starter Theme", "version": "1.0.0", "template": "pollora-starter" } } ``` ### Dev version detection [Section titled “Dev version detection”](#dev-version-detection) When running a dev branch (`dev-develop`, `13.x-dev`, etc.), the command adapts its output: ```plaintext Pollora dev-develop (latest stable: v13.35.5) ``` No misleading “update available” warning is shown for development installations: `development` is `true` in the JSON output, and Site Health reports a development build instead of comparing it with releases. ## Diagnosing a project: `pollora:doctor` [Section titled “Diagnosing a project: pollora:doctor”](#diagnosing-a-project-polloradoctor) `pollora:status` says what is there. `pollora:doctor` says whether it works — and, under each problem, the command that fixes it: ```bash php artisan pollora:doctor ``` ```plaintext ✓ WordPress core patch — The core is patched, and __() is Pollora's. ✗ Composer patches lock — patches.lock.json is older than the framework's patches: Composer applies the old ones, or none. johnpbloch/wordpress-core: Patch __ method in l10n to stop conflicting with Laravel → composer patches-relock && composer patches-repatch ! Theme pattern files — 1 pattern file(s) are never registered by WordPress. patterns/masthead.html: WordPress reads only .php files in patterns/ → Make each one a .php file whose header is a docblock with Title and Slug (/** Title: … Slug: my-theme/… */) 1 error(s), 1 warning(s). ``` It looks for failures that stay silent — the site renders, every command exits 0 — each one met in practice: | Check | What it catches | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | WordPress core patch | the core still declares `__()` (a patch Composer skipped), or `__()` is not Pollora’s | | Composer patches lock | `patches.lock.json` missing, or older than the framework’s patches | | Environment file | `.env` names Pollora does not read (`DB_NAME`, `DB_USER`, `WP_HOME`…), MySQL settings on a sqlite connection | | Configuration and route caches | configuration or routes cached outside production: edits to `.env` or `routes/` are ignored | | Discovery cache | classes added since the discovery cache was written, which are not registered — named, one by one | | Theme, plugin and module builds | no theme; a build missing, or written to another folder than Pollora reads; a hot file pointing at a Vite dev server that is stopped or whose port is not exposed | | Symlinked directories | a theme, plugin or module linked under another name: the build and the site disagree on its folder | | Template placeholders | `%theme_*%` / `%plugin_*%` or `.stub` files left in a theme or plugin copied instead of generated | | Theme pattern files | `.html` files in `patterns/` (WordPress reads only `.php`), patterns without a Title or Slug | | Theme pattern cache | pattern files missing from WordPress’s cached list | | Routes over block templates | `Route::wp()` routes answering in place of a block theme’s templates | | Blocks in the legacy folder | blocks still in `resources/blocks`, which stops loading in v15 | | Asynchronous actions | an ignored `#[Async]`, an unavailable driver, WP-Cron events overdue or queued jobs waiting with nothing to run them — see [Asynchronous Actions](/hooks/async-actions/#something-must-run-the-queue) | The builds, directories, placeholders and blocks are checked for the active theme, every Pollora plugin and every enabled Laravel module. `--json` prints the same for scripts. The command exits `1` when a check finds an error (warnings exit `0`), so it can gate a deploy or a CI job. ### In Site Health [Section titled “In Site Health”](#in-site-health) The same checks appear in WordPress’s **Tools › Site Health**, with a *Pollora* badge, plus one only a web request can make: every block of the theme, the Pollora plugins and the modules is registered. Site Health runs in an administrator’s web request — the boot a visitor gets — which is where a block can be missing while WP-CLI sees it. ## Programmatic Access [Section titled “Programmatic Access”](#programmatic-access) The `SystemInfoCollector` service is registered as a singleton and can be injected into your own code to access system information programmatically: ```php use Pollora\Dashboard\Domain\Services\SystemInfoCollector; class MyController { public function __construct( private readonly SystemInfoCollector $collector ) {} public function health(): array { return $this->collector->collect(); } } ``` # Debugging > See WordPress queries, hooks, the template or route that answered and REST calls in Laravel Debugbar with pollora/debugbar, and add your own tabs. `pollora/debugbar` puts WordPress and Pollora in [Laravel Debugbar](https://github.com/fruitcake/laravel-debugbar). Next to Laravel’s own tabs, every page shows which route or template answered it, the queries WordPress ran through `$wpdb`, the hooks that fired, how WordPress parsed the request, and WordPress’s phases on the timeline. REST and admin-ajax calls appear in the bar’s request list too. The aim is to make Query Monitor unnecessary in a Pollora project. * [Installation](#installation) * [When it runs](#when-it-runs) * [The tabs](#the-tabs) * [REST and admin-ajax requests](#rest-and-admin-ajax-requests) * [In wp-admin](#in-wp-admin) * [Configuration](#configuration) * [Adding your own data](#adding-your-own-data) * [From a WordPress plugin or theme](#from-a-wordpress-plugin-or-theme) * [From a package or module](#from-a-package-or-module) * [With Laravel Debugbar alone](#with-laravel-debugbar-alone) * [Coming from Query Monitor](#coming-from-query-monitor) ## Installation [Section titled “Installation”](#installation) ```bash composer require --dev pollora/debugbar ``` It brings `fruitcake/laravel-debugbar` with it and needs Pollora 13.35.3 or later. Nothing else to register: the service provider is discovered. It is a development dependency on purpose. `composer install --no-dev` leaves it out, so a production deployment never ships it. ## When it runs [Section titled “When it runs”](#when-it-runs) Exactly when Laravel Debugbar does: `DEBUGBAR_ENABLED`, or `APP_DEBUG` when that is not set, and never in the `production` or `testing` environments. Turning the bar off turns everything here off, including `SAVEQUERIES`. To keep Laravel’s tabs and drop Pollora’s, set `DEBUGBAR_POLLORA_ENABLED=false`. ## The tabs [Section titled “The tabs”](#the-tabs) Pollora’s tab comes right after Laravel’s, then the WordPress tabs, prefixed `WP`, then those added by plugins and packages. | Tab | Shows | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Pollora** | What answered the request — a `Route::wp()` route and its condition, the template hierarchy with the Blade view and the conditional that picked it, a Laravel route, or WordPress alone — then the versions, discovery (where each location’s classes came from and how long it took), modules, the theme, async actions registered and queued by this request, WordPress constants and drop-ins | | **Doctor** | A **Run doctor** button that runs `pollora:doctor`’s web checks on demand and lists them, errors first. Nothing runs with the page | | **WP Request** | The rewrite rule that matched, query vars, the queried object, the main query and its results, the conditional tags that are true, the template file and the candidates of each template hierarchy. In wp-admin, the admin page and screen. On a multisite, the site and network, every switch between sites and who made it, and a warning when the request ends still switched | | **WP Queries** | The queries WordPress ran through `$wpdb`, with their time, full backtrace, rows, errors, duplicates, slow ones, and the main query marked, grouped by component: core, a plugin, a theme, a module, the application. Laravel’s **Queries** tab keeps Eloquent’s | | **WP Hooks** | The actions that fired and how often, how many callbacks each had, and the callbacks Pollora registered, by class and method | | **WP Hook timings** | Off by default. The slowest hook callbacks, by their own time (without the hooks they fire in turn) and in total, with their hook, priority, calls and component | | **WP HTTP** | The calls made through `wp_remote_*`: result, time, transport, and who made them; a call a plugin answered in `pre_http_request` says so | | **WP Cache** | Object-cache hits and misses, whether the cache is persistent, the transients set and who set them, OPcache | | **WP Capabilities** | The `current_user_can()` checks: each distinct check once, granted or refused, and how many times it was made | | **WP Blocks** | The blocks rendered by type, with their time and nesting; Pollora’s Blade blocks marked; the block bindings that gave them values | | **WP Assets** | Scripts, styles and script modules, printed in the header or the footer, with dependencies nobody registered; the Vite build or dev server of each theme, plugin and module | | **WP Languages** | The locale, and the translation files WordPress looked for, found or not | | **Timeline** | WordPress loading and the callbacks of `muplugins_loaded`, `init`, `wp_loaded`, `template_redirect`, `wp_head`… beside Laravel’s measures | WordPress queries are traced through two core filters, `log_query_custom_data` and `query`, rather than a `wpdb` class of its own: it works with Pollora’s `db.php` drop-in, and with any other. ## REST and admin-ajax requests [Section titled “REST and admin-ajax requests”](#rest-and-admin-ajax-requests) A WordPress REST request or an admin-ajax call ends with `exit` before Laravel finishes the response, so Laravel Debugbar alone never records it. With this package, the request is stored and its id sent in the `phpdebugbar-id` header: a `fetch()` or XHR made from a page with the bar shows up in the bar’s request list, with all its tabs. A `wp_redirect()` exits too: its request is kept, and the page it leads to shows both. Stored requests are opened through `_debugbar/open`, and the doctor through `_debugbar/pollora/doctor`; Laravel Debugbar limits both to local and private addresses by default. ## In wp-admin [Section titled “In wp-admin”](#in-wp-admin) WordPress prints admin pages itself, without Laravel’s kernel, so Laravel Debugbar alone never shows there. With this package, the bar is printed at the bottom of admin pages and the request is stored like any other. Laravel’s tabs about a request it answered (route, views, session) are left out. The REST calls the block editor makes are listed in the bar’s request list. Set `DEBUGBAR_POLLORA_ADMIN=false` to keep wp-admin without the bar. The front end the Site Editor shows in its canvas gets no second bar; open that request from the bar’s request list. ## Configuration [Section titled “Configuration”](#configuration) ```bash php artisan vendor:publish --tag=debugbar-pollora-config ``` `config/debugbar-pollora.php` turns each tab on or off and sets their options. Each key also reads an environment variable: | Key | Default | Effect | | ---------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collectors.wp_queries` | `true` | Also turns `SAVEQUERIES` on; off, WordPress keeps no queries | | `options.wp_queries.slow_threshold` | `50` | Milliseconds from which a query is highlighted | | `options.wp_queries.soft_limit` / `hard_limit` | `100` / `500` | Past the first, no caller is kept; past the second, queries are left out | | `options.wp_queries.trace` | `true` | Full backtrace, error, rows and component for each query | | `options.wp_hooks.count_filters` | `false` | Count filters too. It listens to every hook call, so it costs on every `apply_filters()` | | `options.wp_hooks.timings` | `false` | Time every hook callback (**WP Hook timings**). Each callback is wrapped in place in `$wp_filter`, so code reading `$wp_filter` directly sees the wrapper; callbacks taking parameters by reference are not timed | | `options.wp_hooks.timings_limit` | `200` | How many callbacks the timings tab lists | | `iframes` | `false` | Print the bar inside pages loaded in an iframe (Site Editor canvas, Customizer preview). Off, those requests are still stored and open from the bar’s request list | | `admin.enabled` | `true` | Show the bar on wp-admin pages | | `admin.hidden_collectors` | `route`, `views`, `session`, `livewire`, `inertia` | Laravel Debugbar tabs left out in wp-admin | | `options.wp_capabilities.backtrace` | `false` | Say who made each distinct capability check | | `options.bridges.query_monitor` | `true` | Keep Query Monitor’s `qm/*` logging actions working | ## Adding your own data [Section titled “Adding your own data”](#adding-your-own-data) Every tab says where its data comes from: Pollora, WordPress, or the name you give. Tab names starting with `wp_` or `pollora` are reserved; prefix yours with your own name. ### From a WordPress plugin or theme [Section titled “From a WordPress plugin or theme”](#from-a-wordpress-plugin-or-theme) Use actions: they need no dependency on the package, and do nothing where it is not installed. ```php add_action('pollora/debugbar/register', function ($bar): void { // A tab of rows $bar->table('acme_cart', 'Acme cart', fn (): array => acme_cart_rows(), origin: 'acme-shop'); // A tab of name => value pairs $bar->variables('acme_info', 'Acme', fn (): array => ['mode' => 'test'], origin: 'acme-shop'); // A section in an existing tab $bar->section('wp_request', 'Acme', fn (): array => ['Cart' => acme_cart_id()]); }); do_action('pollora/debugbar/message', 'Cart {id} rebuilt', 'info', ['id' => $cartId]); do_action('pollora/debugbar/start', 'acme-sync'); // … do_action('pollora/debugbar/stop', 'acme-sync'); ``` The closures run when the bar collects, at the end of the request. ### From a package or module [Section titled “From a package or module”](#from-a-package-or-module) Extend `Pollora\Debugbar\Collector` and tag the class: ```php use Pollora\Debugbar\Collector; use Pollora\Debugbar\CollectorRegistrar; use Pollora\Debugbar\Widget; final class CartCollector extends Collector { public function getName(): string { return 'acme_cart'; } public function title(): string { return 'Acme cart'; } public function origin(): string { return 'acme-shop'; } public function widget(): Widget { return Widget::Table; } public function columns(): array { return ['qty' => 'Quantity']; } protected function data(): array { return ['apple' => ['qty' => 3]]; } } // In a service provider's register(), when the package is installed if (class_exists(Collector::class)) { $this->app->tag([CartCollector::class], CollectorRegistrar::COLLECTORS_TAG); } ``` `widget()` is `Widget::Variables` (the default), `Widget::Table` or `Widget::Queries` (php-debugbar’s SQL statement shape). `icon()` takes one of the icons php-debugbar ships, such as `box`, `table`, `tags` or `bolt`. To add a section to an existing tab instead, implement `Pollora\Debugbar\Contracts\SectionProvider` and tag it `CollectorRegistrar::SECTIONS_TAG`. ### With Laravel Debugbar alone [Section titled “With Laravel Debugbar alone”](#with-laravel-debugbar-alone) `Debugbar::addCollector()`, `debugbar.custom_collectors`, `Debugbar::addMessage()` and `startMeasure()` work as usual. Those tabs keep Debugbar’s place, among Laravel’s. ## Coming from Query Monitor [Section titled “Coming from Query Monitor”](#coming-from-query-monitor) The two can run side by side while you switch. What Query Monitor shows and where it is here: | Query Monitor | Here | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | Queries, by caller and component, duplicates, errors | **WP Queries** | | Request, conditionals, template | **WP Request**, and **Pollora** for the route or Blade view that answered | | Hooks & actions | **WP Hooks** | | HTTP API calls | **WP HTTP** | | Transients, object cache | **WP Cache** | | Capability checks | **WP Capabilities**, on by default here since checks are aggregated | | Blocks | **WP Blocks** | | Scripts, styles | **WP Assets** | | Languages | **WP Languages** | | Environment | **Pollora** (versions, constants, drop-ins) | | PHP errors, doing it wrong | Laravel Debugbar’s **Exceptions** tab; WordPress’s notices go to the `wordpress` log channel ([WordPress Logging](/advanced/logging/)) | | Logs (`qm/debug`…) and timings (`qm/start`, `qm/stop`) | Still work, in **Messages** and the **Timeline** | | Overview | Laravel Debugbar’s time and memory | | Redirects | Kept: the next page shows both requests | | Admin screen | **WP Request**, in wp-admin | | Multisite | **WP Request**, on a multisite | Query Monitor cannot install its own `db.php` next to Pollora’s, so its query panel loses callers and components in a Pollora project; **WP Queries** has them. # Logging > Log WordPress errors, warnings and deprecated function calls through Laravel logging in Pollora, so they reach your log channels without breaking pages. The Pollora framework provides a WordPress error logging system. This system captures WordPress errors, warnings, and deprecated function usage, logging them through Laravel’s logging infrastructure while preventing them from breaking your application. ## Overview [Section titled “Overview”](#overview) The WordPress logging module intercepts and logs various WordPress error types: * **Doing it wrong**: When WordPress functions are used incorrectly * **Deprecated functions**: When deprecated WordPress functions are called * **Deprecated arguments**: When functions are called with deprecated parameters All errors are logged to a dedicated `wordpress` log channel while preventing PHP errors from being displayed to users. ## Architecture [Section titled “Architecture”](#architecture) The logging system follows a strict DDD architecture with clear separation of concerns: ### Domain Layer (`src/Logging/Domain/`) [Section titled “Domain Layer (src/Logging/Domain/)”](#domain-layer-srcloggingdomain) #### Contracts [Section titled “Contracts”](#contracts) * **`WordPressErrorLoggerInterface`**: Defines the contract for logging WordPress errors * **`WordPressErrorHookRegistrarInterface`**: Defines the contract for registering WordPress hooks #### Models [Section titled “Models”](#models) * **`WordPressError`**: Domain entity representing a WordPress error with contextual information * **`WordPressErrorType`**: Enum defining the three types of WordPress errors and their log levels #### Services [Section titled “Services”](#services) * **`WordPressErrorHandler`**: Pure domain service handling error processing logic ### Application Layer (`src/Logging/Application/`) [Section titled “Application Layer (src/Logging/Application/)”](#application-layer-srcloggingapplication) #### Services [Section titled “Services”](#services-1) * **`WordPressErrorLoggingService`**: Orchestrates error handling, builds context, and coordinates between domain and infrastructure layers ### Infrastructure Layer (`src/Logging/Infrastructure/`) [Section titled “Infrastructure Layer (src/Logging/Infrastructure/)”](#infrastructure-layer-srclogginginfrastructure) #### Adapters [Section titled “Adapters”](#adapters) * **`LaravelWordPressErrorLogger`**: Adapts domain logging interface to Laravel’s logging system #### Services [Section titled “Services”](#services-2) * **`WordPressErrorHookRegistrar`**: Registers WordPress hooks and filters using the container pattern #### Providers [Section titled “Providers”](#providers) * **`LoggingServiceProvider`**: Configures dependency injection and initializes the logging system ### Support Layer (`src/Support/`) [Section titled “Support Layer (src/Support/)”](#support-layer-srcsupport) #### Facades [Section titled “Facades”](#facades) * **`WordPressError`**: Laravel facade providing convenient static methods for logging WordPress errors ## Usage [Section titled “Usage”](#usage) The logging system is automatically registered through the main `PolloraServiceProvider`. No manual configuration is required for basic usage. ### Laravel Facade (Recommended) [Section titled “Laravel Facade (Recommended)”](#laravel-facade-recommended) The simplest way to log WordPress errors in Laravel is using the `WordPressError` facade: ```php use Pollora\Support\Facades\WordPressError; // Log a "doing it wrong" error WordPressError::doingItWrong( 'wp_enqueue_script', 'Scripts should be enqueued in wp_enqueue_scripts action', '6.0.0' ); // Log a deprecated function usage WordPressError::deprecatedFunction( 'mysql_query', 'wpdb::prepare()', '3.9.0' ); // Log a deprecated argument usage WordPressError::deprecatedArgument( 'get_posts', 'The "numberposts" parameter is deprecated. Use "posts_per_page" instead.', '4.4.0' ); ``` ### Error Types and Log Levels [Section titled “Error Types and Log Levels”](#error-types-and-log-levels) ```php // Domain model showing error types and their log levels enum WordPressErrorType: string { case DOING_IT_WRONG = 'doing_it_wrong'; // Logs as 'warning' case DEPRECATED_FUNCTION = 'deprecated_function'; // Logs as 'info' case DEPRECATED_ARGUMENT = 'deprecated_argument'; // Logs as 'info' } ``` ### Creating WordPress Errors [Section titled “Creating WordPress Errors”](#creating-wordpress-errors) The `WordPressError` model provides static factory methods: ```php // For "doing it wrong" errors $error = WordPressError::doingItWrong( function: 'wp_enqueue_script', message: 'Scripts should be enqueued in wp_enqueue_scripts action', version: '6.0.0', context: ['url' => 'https://example.com'] ); // For deprecated functions $error = WordPressError::deprecatedFunction( function: 'mysql_query', replacement: 'wpdb::prepare()', version: '3.9.0', context: ['file' => 'plugins/old-plugin/plugin.php'] ); // For deprecated arguments $error = WordPressError::deprecatedArgument( function: 'get_posts', message: 'The "numberposts" parameter is deprecated. Use "posts_per_page" instead.', version: '4.4.0', context: ['caller' => 'my_theme_function()'] ); ``` ## Configuration [Section titled “Configuration”](#configuration) ### Environment Variables [Section titled “Environment Variables”](#environment-variables) Configure the WordPress logging channel through environment variables: ```env # Log level for WordPress errors (debug, info, notice, warning, error, critical, alert, emergency) WORDPRESS_LOG_LEVEL=debug # Number of days to retain WordPress logs WORDPRESS_LOG_DAYS=7 ``` ### Custom Configuration [Section titled “Custom Configuration”](#custom-configuration) Override the default logging configuration in your `config/logging.php`: ```php 'channels' => [ 'wordpress' => [ 'driver' => 'single', 'path' => storage_path('logs/wordpress.log'), 'level' => env('WORDPRESS_LOG_LEVEL', 'debug'), 'days' => env('WORDPRESS_LOG_DAYS', 7), 'replace_placeholders' => true, ], ], ``` ### Advanced Configuration [Section titled “Advanced Configuration”](#advanced-configuration) For more complex setups, you can configure multiple channels or use different drivers: ```php 'channels' => [ 'wordpress' => [ 'driver' => 'stack', 'channels' => ['wordpress-file', 'wordpress-slack'], ], 'wordpress-file' => [ 'driver' => 'daily', 'path' => storage_path('logs/wordpress.log'), 'level' => 'info', 'days' => 14, ], 'wordpress-slack' => [ 'driver' => 'slack', 'url' => env('SLACK_WEBHOOK_URL'), 'username' => 'WordPress Logger', 'emoji' => ':warning:', 'level' => 'warning', ], ], ``` ## Context Information [Section titled “Context Information”](#context-information) The logging system automatically includes contextual information: ### Standard Context [Section titled “Standard Context”](#standard-context) * **type**: Error type (`doing_it_wrong`, `deprecated_function`, `deprecated_argument`) * **function**: Name of the WordPress function * **version**: WordPress version where the issue was introduced * **message**: Error description (HTML tags stripped) * **url**: Current request URL * **method**: HTTP method * **ip**: Client IP address ### Development Context [Section titled “Development Context”](#development-context) In local environments, additional debugging information is included: * **backtrace**: Clean stack trace (limited to 10 frames, excluding internal logging calls) ### Example Log Entry [Section titled “Example Log Entry”](#example-log-entry) ```json { "message": "WordPress: wp_enqueue_script called incorrectly", "context": { "type": "doing_it_wrong", "function": "wp_enqueue_script", "version": "6.0.0", "message": "Scripts should be enqueued in wp_enqueue_scripts action", "url": "https://example.com/admin", "method": "GET", "ip": "192.168.1.100", "backtrace": [ "#0 MyTheme\\Services\\AssetService::enqueueScripts() in /themes/mytheme/app/Services/AssetService.php:45", "#1 MyTheme\\Providers\\ThemeServiceProvider::boot() in /themes/mytheme/app/Providers/ThemeServiceProvider.php:23" ] }, "level": "warning", "level_name": "WARNING", "channel": "wordpress", "datetime": "2024-01-15T10:30:45.123456+00:00" } ``` ## Extending the System [Section titled “Extending the System”](#extending-the-system) ### Custom Error Logger [Section titled “Custom Error Logger”](#custom-error-logger) Implement your own logger by creating a class that implements `WordPressErrorLoggerInterface`: ```php use Pollora\Logging\Domain\Contracts\WordPressErrorLoggerInterface; use Pollora\Logging\Domain\Models\WordPressError; class CustomWordPressErrorLogger implements WordPressErrorLoggerInterface { public function logError(WordPressError $error): void { // Custom logging implementation $this->sendToExternalService($error); } } ``` Then bind it in your service provider: ```php $this->app->bind( WordPressErrorLoggerInterface::class, CustomWordPressErrorLogger::class ); ``` ### Custom Hook Registration [Section titled “Custom Hook Registration”](#custom-hook-registration) Implement custom hook registration by implementing `WordPressErrorHookRegistrarInterface`: ```php use Pollora\Logging\Domain\Contracts\WordPressErrorHookRegistrarInterface; class CustomWordPressErrorHookRegistrar implements WordPressErrorHookRegistrarInterface { public function registerErrorHandlers(): void { // Custom hook registration logic add_action('doing_it_wrong_run', [$this, 'handleError'], 10, 3); // ... register other hooks } } ``` ## Testing [Section titled “Testing”](#testing) The DDD architecture makes the logging system highly testable: ### Testing Domain Logic [Section titled “Testing Domain Logic”](#testing-domain-logic) ```php use Pollora\Logging\Domain\Models\WordPressError; use Pollora\Logging\Domain\Models\WordPressErrorType; class WordPressErrorTest extends TestCase { public function test_doing_it_wrong_error_creation(): void { $error = WordPressError::doingItWrong( 'wp_enqueue_script', 'Invalid usage', '6.0.0' ); $this->assertEquals(WordPressErrorType::DOING_IT_WRONG, $error->type); $this->assertEquals('warning', $error->getLogLevel()); $this->assertEquals('WordPress: wp_enqueue_script called incorrectly', $error->getLogMessage()); } } ``` ### Testing Application Services [Section titled “Testing Application Services”](#testing-application-services) ```php use Pollora\Logging\Application\Services\WordPressErrorLoggingService; use Pollora\Logging\Domain\Services\WordPressErrorHandler; class WordPressErrorLoggingServiceTest extends TestCase { public function test_handles_doing_it_wrong(): void { $mockHandler = $this->mock(WordPressErrorHandler::class); $mockHandler->shouldReceive('handleDoingItWrong') ->once() ->with('wp_enqueue_script', 'Invalid usage', '6.0.0', Mockery::type('array')); $service = new WordPressErrorLoggingService( $mockHandler, app(), request() ); $service->handleDoingItWrong('wp_enqueue_script', 'Invalid usage', '6.0.0'); } } ``` ### Testing with Facade [Section titled “Testing with Facade”](#testing-with-facade) ```php use Pollora\Support\Facades\WordPressError; use Pollora\Logging\Application\Services\WordPressErrorLoggingService; class WordPressErrorFacadeTest extends TestCase { public function test_facade_logs_doing_it_wrong(): void { $mockService = $this->mock(WordPressErrorLoggingService::class); $mockService->shouldReceive('handleDoingItWrong') ->once() ->with('wp_enqueue_script', 'Invalid usage', '6.0.0'); $this->app->instance(WordPressErrorLoggingService::class, $mockService); WordPressError::doingItWrong('wp_enqueue_script', 'Invalid usage', '6.0.0'); } } ``` ## Performance Considerations [Section titled “Performance Considerations”](#performance-considerations) ### Lazy Hook Registration [Section titled “Lazy Hook Registration”](#lazy-hook-registration) The system uses Laravel’s container to resolve dependencies only when WordPress hooks are triggered, avoiding circular dependencies and reducing memory usage. ### Backtrace Limitations [Section titled “Backtrace Limitations”](#backtrace-limitations) * Backtraces are only generated in local environments * Limited to 10 stack frames to prevent memory issues * Arguments are excluded from backtraces for performance ### Log Rotation [Section titled “Log Rotation”](#log-rotation) Configure appropriate log rotation to prevent disk space issues: ```php 'wordpress' => [ 'driver' => 'daily', 'path' => storage_path('logs/wordpress.log'), 'level' => 'info', 'days' => 7, // Keep logs for 7 days ], ``` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Debugging [Section titled “Debugging”](#debugging) Enable debug mode to see backtraces in logs: ```env APP_ENV=local WORDPRESS_LOG_LEVEL=debug ``` ### Log File Locations [Section titled “Log File Locations”](#log-file-locations) Default log file locations: * **Single channel**: `storage/logs/wordpress.log` * **Daily rotation**: `storage/logs/wordpress-YYYY-MM-DD.log` * **Laravel default**: `storage/logs/laravel.log` (if fallback occurs) ## Best Practices [Section titled “Best Practices”](#best-practices) 1. **Use appropriate log levels**: Set `WORDPRESS_LOG_LEVEL=info` in production to avoid debug noise 2. **Monitor log size**: Implement log rotation in production environments 3. **Custom context**: Add relevant context when manually creating WordPress errors 4. **Error handling**: The system prevents WordPress errors from breaking your application, but fix underlying issues 5. **Performance**: Avoid logging in tight loops; the system is designed for occasional WordPress errors, not high-frequency logging ## Integration with Monitoring [Section titled “Integration with Monitoring”](#integration-with-monitoring) The WordPress logging system integrates seamlessly with Laravel’s logging infrastructure, making it compatible with: * **Laravel Telescope**: View WordPress errors in the logs section * **Flare**: Automatic error reporting and grouping * **Bugsnag/Sentry**: External error monitoring services * **ELK Stack**: Elasticsearch, Logstash, and Kibana for log analysis * **Custom dashboards**: Parse structured log data for monitoring dashboards # Modules > Organize a Pollora project with Laravel Modules: when to choose a module over a WordPress plugin, and how to create, enable and auto-discover modules. 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](https://laravelmodules.com/) package developed by Nwidart. ## Philosophy: Module vs WordPress Plugin [Section titled “Philosophy: Module vs WordPress Plugin”](#philosophy-module-vs-wordpress-plugin) 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. ## What is a Module? [Section titled “What is a Module?”](#what-is-a-module) 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. ## Creating a Module [Section titled “Creating a Module”](#creating-a-module) ```shell php artisan pollora:make:module Portfolio ``` The command downloads the [module-default](https://github.com/Pollora/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: ```plaintext 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. ### `module:make` [Section titled “module:make”](#modulemake) 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. ### Generating into a module [Section titled “Generating into a module”](#generating-into-a-module) 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\`: ```shell php artisan pollora:make:post-type Project --module=Portfolio php artisan pollora:make:block project-card --module=Portfolio ``` A block lands in `resources/views/blocks/project-card`, named `portfolio/project-card`. ## Frontend [Section titled “Frontend”](#frontend) A module builds like a theme or a plugin, with the same Vite, Tailwind CSS v4 and block tooling, through [`@pollora/vite-config`](https://github.com/Pollora/vite-config): Modules/Portfolio/vite.config.js ```js import { defineConfig } from 'vite'; import pollora from '@pollora/vite-config'; export default defineConfig({ plugins: [pollora({ type: 'module', name: 'portfolio' })], }); ``` | | | | ---------- | ---------------------------------------------------------------------- | | Build | `public/build/module/`, with its `manifest.json` | | Hot file | `public/.hot` | | Dev server | DDEV-aware, port 5175 (`VITE_PORT` to change it) | | Entries | `resources/assets/app.js`, and every block in `resources/views/blocks` | ```shell cd Modules/Portfolio npm install npm run dev # hot reload npm run build ``` Every enabled module with a `vite.config.js` gets the `module.` asset container: ```php use Pollora\Support\Facades\Asset; Asset::add('portfolio/app', 'app.js') ->container('module.portfolio') ->toFrontend() ->useVite(); ``` ### A module made by an older `module:make` [Section titled “A module made by an older module:make”](#a-module-made-by-an-older-modulemake) nwidart’s stock `vite.config.js` builds into `public/build-`, where Pollora never looks; `pollora:doctor` says so. Move the module onto the template’s build: ```shell php artisan pollora:module:frontend Portfolio ``` It 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. ## Dependency Management [Section titled “Dependency Management”](#dependency-management) 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: ```json "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`. ## Installing a Module with Composer [Section titled “Installing a Module with Composer”](#installing-a-module-with-composer) A module published as a Composer package installs with `composer require`, straight into `Modules/`, where nwidart finds it: ```bash composer require pollora/meilifacets ``` The skeleton routes it there with one `installer-paths` rule, handled by `composer/installers` (already used for WordPress plugins and themes): ```json "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/installers` applies the first rule that matches, and a `vendor:` rule ignores the package type: placed first, it would also send Pollora’s WordPress plugins (`pollora/mcp-connector`, `pollora/ai-visibility`) to `Modules/`. Last, it only catches what the rules above did not, which are the `pollora/*` modules of type `laravel-library`; Pollora’s other packages (`library`, `project`) are not handled by `composer/installers` and stay in `vendor/`. * **A module from another vendor needs its own line**, for instance `"Modules/{$name}/": ["vendor:pollora", "vendor:acme"]`. The rule does not target `type:laravel-library` alone: dozens of ordinary Laravel packages declare that type and would land in `Modules/`. * 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": false` to `extra.merge-plugin` (see Dependency Management). ### Publishing a module [Section titled “Publishing a module”](#publishing-a-module) The module’s `composer.json` declares the type and the folder name: ```json { "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. ### Enabling it [Section titled “Enabling it”](#enabling-it) 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). ### Updates [Section titled “Updates”](#updates) 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: ```shell php artisan pollora:module:outdated # checks now php artisan pollora:module:outdated --json ``` An 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. ## Enabling and Disabling Modules [Section titled “Enabling and Disabling Modules”](#enabling-and-disabling-modules) ```shell php artisan module:enable Portfolio php artisan module:disable Portfolio ``` Module providers register during Laravel’s register phase, before anything can switch them: a change applies from the next request. ### Plugins › Modules [Section titled “Plugins › Modules”](#plugins--modules) 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=false` turns 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. ### Where the state lives [Section titled “Where the state lives”](#where-the-state-lives) 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: ```shell php artisan vendor:publish --tag=pollora-modules ``` ```php // 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: ```shell php artisan pollora:module:connector # where the states live now php artisan pollora:module:connector database --import # copy them, then set MODULES_CONNECTOR=database ``` **Locked 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`: ```php 'connectors' => [ 'redis' => ['class' => App\Modules\RedisStateConnector::class], ], ``` ```php // 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. ## Automatic Discovery System [Section titled “Automatic Discovery System”](#automatic-discovery-system) 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 ```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`. ### Manual Discovery Control [Section titled “Manual Discovery Control”](#manual-discovery-control) You can also trigger discovery manually using helper functions: ```php // 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: ```php use Pollora\Modules\Domain\Contracts\ModuleDiscoveryOrchestratorInterface; $discovery = app(ModuleDiscoveryOrchestratorInterface::class); $discovery->discover('/path/to/module'); ``` ## Best Practices [Section titled “Best Practices”](#best-practices) * 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 `tests` directory (`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. ## Checks [Section titled “Checks”](#checks) `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`. ## Learn More [Section titled “Learn More”](#learn-more) To further explore the advanced features of the Laravel Modules package, refer to: * [Laravel Modules Official Documentation](https://laravelmodules.com/docs) * [Artisan Commands Specific to Laravel Modules](https://laravelmodules.com/docs/advanced/artisan-commands) # Plugin Development > Build WordPress plugins with Pollora: scaffold them from the CLI, then use service providers, attribute-based hooks, autoloading and Vite assets. Pollora provides a comprehensive plugin development system that bridges WordPress plugin development with Laravel’s modern architecture patterns. This documentation covers everything you need to know about creating, managing, and deploying plugins using the Pollora framework. ## Table of Contents [Section titled “Table of Contents”](#table-of-contents) * [Overview](#overview) * [Quick Start](#quick-start) * [Plugin Architecture](#plugin-architecture) * [Creating a Plugin](#creating-a-plugin) * [Plugin Structure](#plugin-structure) * [Service Providers](#service-providers) * [Attribute-Based Hooks](#attribute-based-hooks) * [Autoloading](#autoloading) * [Plugin Management](#plugin-management) * [Configuration](#configuration) * [Assets & Vite Integration](#assets--vite-integration) * [Views](#views) * [Frontend Development](#frontend-development) * [Translations](#translations) * [Testing](#testing) * [Deployment](#deployment) * [Best Practices](#best-practices) ## Overview [Section titled “Overview”](#overview) The Pollora plugin system extends WordPress plugin development by providing: * **Laravel-style Architecture**: Service providers, dependency injection, and modern PHP patterns * **PSR-4 Autoloading**: Automatic class loading with fixed namespace conventions `Plugin\{PluginName}\` * **Attribute-driven Configuration**: Use PHP 8 attributes for declarative hook registration * **Command-line Tools**: Artisan commands for plugin scaffolding and management * **Modern Asset Management**: Vite integration with hot reload and Tailwind CSS support * **Automatic Module Discovery**: Views, routes, translations automatically handled * **Comprehensive Testing**: Built-in testing support with PHPUnit ## Quick Start [Section titled “Quick Start”](#quick-start) ### Creating Your First Plugin [Section titled “Creating Your First Plugin”](#creating-your-first-plugin) Generate a new plugin using the Artisan command: ```bash # Create a plugin with modern asset management (recommended for frontend-heavy plugins) php artisan pollora:make:plugin my-awesome-plugin \ --plugin-author="John Doe" \ --plugin-author-uri="https://johndoe.com" \ --plugin-uri="https://github.com/johndoe/my-awesome-plugin" \ --plugin-description="An awesome plugin built with Pollora" \ --plugin-version="1.0.0" \ --asset=true # Or create a minimal plugin without assets (good for backend-focused plugins) php artisan pollora:make:plugin my-awesome-plugin \ --plugin-author="John Doe" \ --plugin-author-uri="https://johndoe.com" \ --plugin-uri="https://github.com/johndoe/my-awesome-plugin" \ --plugin-description="An awesome plugin built with Pollora" \ --plugin-version="1.0.0" \ --asset=false ``` This creates a complete plugin structure with: * Main plugin file with WordPress headers * Service provider for dependency injection * Configuration files * View templates with Blade support * Asset directories with Tailwind CSS (if `--asset=true` is used) * JavaScript and CSS files with modern tooling (if `--asset=true` is used) * Package.json with development dependencies (if `--asset=true` is used) * Vite configuration for asset compilation (if `--asset=true` is used) ### Plugin Registration [Section titled “Plugin Registration”](#plugin-registration) Plugins are registered with the Pollora framework using the `pollora_register()` helper and the `ModuleType` enum. This replaces all manual registrar wiring with a single, type-safe call: ```php **Note**: If you create a plugin with `--asset=false` or without the `--asset` option, these files will be excluded and no npm commands will be run during plugin creation. ## Plugin Structure [Section titled “Plugin Structure”](#plugin-structure) ### Plugin Types [Section titled “Plugin Types”](#plugin-types) Pollora supports two types of plugin structures depending on your needs: #### With Assets (`--asset=true`) [Section titled “With Assets (--asset=true)”](#with-assets---assettrue) For plugins that require modern frontend development with JavaScript, CSS, and build tools: ```plaintext public/content/plugins/my-awesome-plugin/ ├── app/ # PSR-4 autoloaded application code │ ├── Providers/ # Service providers (auto-discovered) │ │ ├── AssetServiceProvider.php # Asset management │ │ └── PluginServiceProvider.php # Plugin services │ └── MyAwesomePluginPlugin.php # Main plugin class ├── resources/ # Laravel-style resources │ ├── assets/ # Vite-managed assets │ │ ├── app.js # Main JavaScript file │ │ ├── admin.js # Admin JavaScript │ │ └── app.css # Main CSS with Tailwind │ └── views/ # Blade templates ├── vite.config.js # Vite configuration (with Gutenberg block support) ├── package.json # NPM dependencies └── my-awesome-plugin.php # Main plugin file ``` #### Without Assets (`--asset=false` or default) [Section titled “Without Assets (--asset=false or default)”](#without-assets---assetfalse-or-default) For plugins that focus on backend functionality without complex frontend requirements: ```plaintext public/content/plugins/my-awesome-plugin/ ├── app/ # PSR-4 autoloaded application code │ ├── Providers/ # Service providers (auto-discovered) │ │ └── PluginServiceProvider.php # Plugin services only │ └── MyAwesomePluginPlugin.php # Main plugin class ├── resources/ # Laravel-style resources │ └── views/ # Blade templates only ├── config/ # Configuration files │ └── plugin.php # Plugin configuration └── my-awesome-plugin.php # Main plugin file ``` ### Main Plugin Class with Attributes [Section titled “Main Plugin Class with Attributes”](#main-plugin-class-with-attributes) The main plugin class uses PHP 8 attributes for clean hook registration: ```php slug, false, dirname(plugin_basename(MY_AWESOME_PLUGIN_PLUGIN_FILE)) . '/languages' ); } /** * Example admin initialization. */ #[Action('admin_init', priority: 10)] public function onAdminInit(): void { // Initialize admin-specific functionality } /** * Example admin menu setup. */ #[Action('admin_menu', priority: 10)] public function addAdminMenu(): void { // Add admin menu items // add_options_page( // 'My Awesome Plugin Settings', // 'My Plugin', // 'manage_options', // $this->slug, // [$this, 'renderSettingsPage'] // ); } } ``` ## Service Providers [Section titled “Service Providers”](#service-providers) ### Plugin Service Provider [Section titled “Plugin Service Provider”](#plugin-service-provider) Service providers are automatically discovered and registered by the Modules system: ```php app->singleton(CustomService::class, function ($app) { // return new CustomService(); // }); } /** * Bootstrap plugin services. */ public function boot(): void { // Add your custom bootstrap logic here // Note: Views, routes, translations, assets are automatically handled by the Modules system // Example of custom boot logic: // $this->registerCustomValidationRules(); // $this->extendExistingServices(); } } ``` ### Asset Service Provider [Section titled “Asset Service Provider”](#asset-service-provider) Assets are managed through a dedicated service provider with Vite integration: ```php config = file_exists($configPath) ? require $configPath : []; } public function boot(): void { $this->registerPluginAssets(); } protected function registerPluginAssets(): void { $containerName = $this->getContainerName(); $slug = $this->config['slug'] ?? 'my-awesome-plugin'; // Admin assets Asset::add("{$slug}/admin", 'admin.js') ->container($containerName) ->toBackend() ->useVite(); // Plugin styles Asset::add("{$slug}/styles", 'app.css') ->container($containerName) ->toFrontend() ->useVite(); } protected function getContainerName(): string { $slug = $this->config['slug'] ?? 'my-awesome-plugin'; return "plugin.{$slug}"; } } ``` ## Attribute-Based Hooks [Section titled “Attribute-Based Hooks”](#attribute-based-hooks) ### Using Action Attributes [Section titled “Using Action Attributes”](#using-action-attributes) Instead of manual `add_action()` calls, use PHP 8 attributes: ```php use Pollora\Attributes\Action; class MyPluginClass { #[Action('init', priority: 10)] public function onInit(): void { // Code to execute for the 'init' WordPress action } #[Action('wp_enqueue_scripts', priority: 10)] public function enqueueScripts(): void { // Enqueue scripts and styles } // Multiple action hooks on the same method #[Action('wp_loaded', priority: 20)] #[Action('template_redirect', priority: 10)] public function onWordPressLoaded(): void { // Code that runs on multiple hooks } } ``` ### Using Filter Attributes [Section titled “Using Filter Attributes”](#using-filter-attributes) ```php use Pollora\Attributes\Filter; class MyPluginClass { #[Filter('the_content', priority: 10)] public function modifyContent(string $content): string { return str_replace('old', 'new', $content); } } ``` ## Autoloading [Section titled “Autoloading”](#autoloading) ### PSR-4 Autoloading [Section titled “PSR-4 Autoloading”](#psr-4-autoloading) Plugins use PSR-4 autoloading with the namespace `Plugin\{PluginName}\`: app/Services/ExampleService.php ```php namespace Plugin\MyAwesomePlugin\Services; class ExampleService { public function doSomething(): string { return 'Hello from plugin service!'; } } ``` ### Source Directories [Section titled “Source Directories”](#source-directories) The autoloader looks for classes in these directories (in order): 1. `app/` (preferred) 2. `src/` (fallback) ## Plugin Management [Section titled “Plugin Management”](#plugin-management) ### Listing Plugins [Section titled “Listing Plugins”](#listing-plugins) ```bash # List all plugins php artisan pollora:plugin:list # List only active plugins php artisan pollora:plugin:list --active # List with detailed information php artisan pollora:plugin:list --detailed # Output as JSON php artisan pollora:plugin:list --format=json ``` ### Plugin Status [Section titled “Plugin Status”](#plugin-status) ```bash # Show all plugin status php artisan pollora:plugin:status # Show specific plugin details php artisan pollora:plugin:status my-awesome-plugin # Show only active plugins php artisan pollora:plugin:status --active ``` ### Programmatic Access [Section titled “Programmatic Access”](#programmatic-access) ```php use Pollora\Plugin\Application\Services\PluginManager; $pluginManager = app(PluginManager::class); // Get all plugins $plugins = $pluginManager->getAllPlugins(); // Find specific plugin $plugin = $pluginManager->findPlugin('my-awesome-plugin'); // Check if plugin is active $isActive = $pluginManager->isPluginActive('my-awesome-plugin'); // Get plugin information $info = $pluginManager->getPluginInfo('my-awesome-plugin'); ``` ## Configuration [Section titled “Configuration”](#configuration) ### Plugin Configuration [Section titled “Plugin Configuration”](#plugin-configuration) The plugin configuration is automatically loaded by the Modules system: config/plugin.php ```php return [ 'name' => 'My Awesome Plugin', 'version' => '1.0.0', 'slug' => 'my-awesome-plugin', // Vite asset container configuration 'assets' => [ 'version' => '1.0.0', 'assets_path' => 'resources/assets', 'asset_container' => [ 'hot_file' => public_path('my-awesome-plugin.hot'), 'build_directory' => 'build/plugins/my-awesome-plugin', 'manifest_path' => 'manifest.json', 'base_path' => 'resources/assets/', 'module_path' => __DIR__ . '/../', 'module_type' => 'plugin', ], ], 'features' => [ 'admin_menu' => true, 'settings_page' => true, 'frontend_scripts' => true, 'ajax_handlers' => true, ], 'admin_menu' => [ 'page_title' => 'My Plugin Settings', 'menu_title' => 'My Plugin', 'capability' => 'manage_options', 'menu_slug' => 'my-plugin-settings', ], ]; ``` Access configuration in your plugin: ```php $config = config('plugin.plugin'); $features = config('plugin.plugin.features'); ``` ## Assets & Vite Integration [Section titled “Assets & Vite Integration”](#assets--vite-integration) ### Modern Asset Management [Section titled “Modern Asset Management”](#modern-asset-management) Pollora plugins support modern asset management with Vite, including hot reload and Tailwind CSS: ### Vite Configuration [Section titled “Vite Configuration”](#vite-configuration) The plugin Vite configuration includes **Gutenberg block support** out of the box. Block entries in `resources/views/blocks/` are automatically discovered and compiled alongside regular assets: vite.config.js ```javascript import { defineConfig } from "vite"; import laravel, { refreshPaths } from 'laravel-vite-plugin'; import { wordpressPlugin } from '@roots/vite-plugin'; import { globSync } from 'glob'; import path from 'path'; import tailwindcss from '@tailwindcss/vite'; const pluginName = path.basename(__dirname); const port = 5174; // Different port for plugins (5173 for themes) const publicDirectory = "../../../../public"; // Auto-discover Gutenberg block entries const blockEntries = globSync([ './resources/views/blocks/*/{index,view}.{js,jsx,ts,tsx}', './resources/views/blocks/*/{editor,style}.css', ]) .reduce((acc, file) => { acc[file.replace(/^\.\//, '').replace(/\.\w+$/, '')] = file; return acc; }, {}); const hasBlocks = Object.keys(blockEntries).length > 0; const getPluginConfig = () => ({ base: "/build/plugin/" + pluginName, input: ["./resources/assets/app.js", ...Object.values(blockEntries)], publicDirectory, hotFile: path.join(publicDirectory, `${pluginName}.hot`), buildDirectory: path.join("build", "plugin", pluginName), refresh: [ // Blade only under resources/views, so block JSX keeps HMR ...refreshPaths.filter((refreshPath) => refreshPath !== 'resources/views/**'), 'public/content/plugins/'+pluginName+'/resources/views/**/*.blade.php', 'resources/views/**/*.blade.php', 'public/content/plugins/'+pluginName+'/app/**/*.php', ], }); export default defineConfig({ base: "/build/plugin/" + pluginName, build: { emptyOutDir: false, }, plugins: [ tailwindcss(), laravel(getPluginConfig()), ...(hasBlocks ? [wordpressPlugin()] : []), { name: "blade", handleHotUpdate({ file, server }) { if (file.endsWith(".blade.php") || file.endsWith(".php")) { server.ws.send({ type: "full-reload", path: "*" }); } }, }, ], // Server configuration with Docker/DDEV detection... }); ``` Key points: * **Block auto-discovery**: `globSync` scans `resources/views/blocks/*/` for entry files * **Blade-only full reloads under `resources/views`**: block scripts there keep hot module replacement * **Conditional `wordpressPlugin()`**: Only loaded when blocks exist (generates `editor.deps.json` for WordPress dependencies) * **Blade/PHP HMR**: Full reload on PHP/Blade file changes * **Docker/DDEV aware**: Automatic detection of container environments with proper HMR configuration > See [Gutenberg Blocks](/blocks/gutenberg-blocks/) for detailed documentation on creating Gutenberg blocks. ### Package.json [Section titled “Package.json”](#packagejson) The plugin template includes all dependencies needed for modern asset compilation and Gutenberg block development: ```json { "name": "my-awesome-plugin", "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "devDependencies": { "@roots/vite-plugin": "^2.0.0", "@tailwindcss/vite": "^4.2.3", "@wordpress/block-editor": "^14.0.0", "@wordpress/blocks": "^14.0.0", "@wordpress/components": "^29.0.0", "@wordpress/element": "^6.0.0", "@wordpress/i18n": "^5.0.0", "glob": "^11.0.0", "laravel-vite-plugin": "^3.0.1", "postcss": "^8.5.6", "tailwindcss": "^4.2.3", "vite": "^8.0.9" } } ``` > The `@wordpress/*` and `@roots/vite-plugin` packages are included by default so plugins are ready for Gutenberg block development without additional setup. If your plugin doesn’t use blocks, these packages are simply unused — no performance impact. > > **Note:** No `tailwind.config.js` or `postcss.config.mjs` is needed — Tailwind v4 auto-detects source files via the `@tailwindcss/vite` plugin. See [Gutenberg Blocks](/blocks/gutenberg-blocks/) for using Tailwind in Gutenberg blocks. ### Asset Container [Section titled “Asset Container”](#asset-container) Assets are automatically registered with a plugin-specific container: ```php // Automatic asset registration via AssetServiceProvider Asset::add('my-plugin/app', 'app.js') ->container('plugin.my-awesome-plugin') ->toFrontend() ->useVite(); Asset::add('my-plugin/styles', 'app.css') ->container('plugin.my-awesome-plugin') ->toFrontend() ->useVite(); ``` ### Development Workflow [Section titled “Development Workflow”](#development-workflow) ```bash # Install dependencies npm install # Start development server with hot reload npm run dev # Build for production npm run build ``` ## Views [Section titled “Views”](#views) ### Automatic View Registration [Section titled “Automatic View Registration”](#automatic-view-registration) Plugin views are automatically discovered and registered by the Modules system. Views are located in `resources/views/` and are accessible via the plugin namespace: ```php // Render a plugin view return view('my-awesome-plugin::welcome', ['data' => $someData]); // In a controller or plugin method public function showPage() { $data = [ 'title' => 'My Plugin Page', 'content' => 'Hello from plugin!' ]; return view('my-awesome-plugin::admin.settings', $data); } ``` ### Blade Templates [Section titled “Blade Templates”](#blade-templates) Create Blade templates in the `resources/views/` directory: ```blade {{-- resources/views/welcome.blade.php --}} @extends('layouts.app') @section('content')

Welcome to {{ $title }}

{{ $content }}

@endsection ``` ### View Paths [Section titled “View Paths”](#view-paths) The following view paths are automatically registered: * `{plugin}/resources/views` (primary) * `{plugin}/views` (fallback) ### View Namespaces [Section titled “View Namespaces”](#view-namespaces) Views are automatically namespaced with the plugin slug: * `my-awesome-plugin::template-name` * `my-awesome-plugin::admin.settings` * `my-awesome-plugin::components.button` ## Frontend Development [Section titled “Frontend Development”](#frontend-development) ### JavaScript Structure [Section titled “JavaScript Structure”](#javascript-structure) resources/assets/app.js ```javascript import './app.css'; /** * Main JavaScript file for My Awesome Plugin. */ class MyAwesomePluginPlugin { constructor() { this.init(); } init() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.onDOMReady()); } else { this.onDOMReady(); } } onDOMReady() { console.log('My Awesome Plugin loaded'); this.setupPlugin(); } setupPlugin() { // Add your plugin-specific initialization code here } /** * Utility method for AJAX requests */ async ajaxRequest(action, data = {}) { const formData = data instanceof FormData ? data : new FormData(); if (!(data instanceof FormData)) { Object.entries(data).forEach(([key, value]) => { formData.append(key, value); }); } formData.append('action', action); formData.append('nonce', window.my_awesome_plugin_ajax?.nonce || ''); try { const response = await fetch(window.my_awesome_plugin_ajax?.ajax_url || '/wp-admin/admin-ajax.php', { method: 'POST', body: formData }); return await response.json(); } catch (error) { console.error('AJAX request failed:', error); throw error; } } } // Initialize the plugin new MyAwesomePluginPlugin(); ``` ### CSS with Tailwind [Section titled “CSS with Tailwind”](#css-with-tailwind) resources/assets/app.css ```css @import "tailwindcss"; /* Plugin-specific styles */ .my-awesome-plugin-container { max-width: 56rem; margin: 0 auto; padding: 1rem; } .my-awesome-plugin-card { background-color: white; border-radius: 0.5rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); padding: 1.5rem; border: 1px solid #e5e7eb; } ``` ### Tailwind CSS in Blocks [Section titled “Tailwind CSS in Blocks”](#tailwind-css-in-blocks) Tailwind v4 auto-detects source files — no `tailwind.config.js` needed. For Gutenberg blocks, use `@import "tailwindcss" source(".")` in the block’s `style.css` to scope Tailwind scanning to the block directory: resources/views/blocks/my-block/style.css ```css @import "tailwindcss" source("."); .wp-block-my-plugin-my-block { @apply p-6 bg-white rounded-xl shadow-sm; } ``` This ensures utility classes from block JSX are generated and available in both the editor and frontend. See [Gutenberg Blocks](/blocks/gutenberg-blocks/) for full details. ## Translations [Section titled “Translations”](#translations) ### Automatic Loading [Section titled “Automatic Loading”](#automatic-loading) Plugin translations are automatically loaded by the Modules system from the `languages/` directory. ### Text Domain [Section titled “Text Domain”](#text-domain) Use your plugin slug as the text domain: ```php // In PHP __('Hello World', 'my-awesome-plugin'); _e('Hello World', 'my-awesome-plugin'); sprintf(__('Hello %s', 'my-awesome-plugin'), $name); // In Blade templates {{ __('Hello World', 'my-awesome-plugin') }} ``` ### Language Files [Section titled “Language Files”](#language-files) Create `.pot`, `.po`, and `.mo` files in the `languages/` directory following WordPress standards. ## Testing [Section titled “Testing”](#testing) ### Unit Tests [Section titled “Unit Tests”](#unit-tests) Create tests in the `tests/` directory: ```php doSomething(); $this->assertEquals('Hello from plugin service!', $result); } } ``` ### Running Tests [Section titled “Running Tests”](#running-tests) ```bash # Run all framework tests composer test # Run plugin-specific tests vendor/bin/phpunit tests/Plugin/MyAwesomePlugin/ ``` ## Deployment [Section titled “Deployment”](#deployment) ### Plugin Packaging [Section titled “Plugin Packaging”](#plugin-packaging) Create a deployment script or use the built-in tools to package your plugin: ```bash # Build assets for production npm run build # Create plugin zip file zip -r my-awesome-plugin.zip my-awesome-plugin/ \ -x "*.git*" "node_modules/*" "tests/*" "*.dev.*" "vite.config.js" "tailwind.config.js" ``` ### WordPress Plugin Directory [Section titled “WordPress Plugin Directory”](#wordpress-plugin-directory) For submission to the WordPress plugin directory: 1. Ensure all requirements are met 2. Include proper plugin headers 3. Follow WordPress coding standards 4. Include proper documentation 5. Test thoroughly ## Best Practices [Section titled “Best Practices”](#best-practices) ### Code Organization [Section titled “Code Organization”](#code-organization) * Use service providers for dependency injection * Keep controllers thin, services fat * Use PHP 8 attributes for declarative hook registration * Follow PSR-4 autoloading standards * Implement proper error handling * Let the Modules system handle discovery automatically ### Modern Development [Section titled “Modern Development”](#modern-development) * Use Vite for asset compilation and hot reload * Leverage Tailwind CSS for styling * Write modern JavaScript with ES6+ features * Use Blade templates for views * Follow Laravel conventions where applicable ### Security [Section titled “Security”](#security) * Always sanitize input data * Use WordPress nonces for form submissions * Validate user capabilities * Escape output data * Use prepared SQL statements ### Performance [Section titled “Performance”](#performance) * Load assets only when needed via conditional asset registration * Use WordPress transients for caching * Optimize database queries * Implement lazy loading where possible * Leverage Vite’s code splitting features ### WordPress Integration [Section titled “WordPress Integration”](#wordpress-integration) * Follow WordPress coding standards * Use WordPress hooks and filters appropriately * Respect WordPress conventions * Ensure compatibility with popular plugins * Test with different themes ### Module System Integration [Section titled “Module System Integration”](#module-system-integration) * Let the Modules system handle automatic discovery * Don’t duplicate functionality (views, routes, translations, etc.) * Use the mutualized asset and view management * Follow the attribute-based hook pattern * Keep service providers minimal and focused This comprehensive guide reflects the modern plugin development approach with Pollora, emphasizing automatic discovery, modern tooling, and clean architecture patterns. # REST API > Build WordPress REST API endpoints in Pollora with the WpRestRoute attribute: define routes and methods, and control access with permission classes. Pollora provides a clean and structured way to declare REST API routes for WordPress using PHP attributes. This system enables developers to define endpoints, specify HTTP methods, and implement permission handling in an intuitive way. ## Table of Contents [Section titled “Table of Contents”](#table-of-contents) * [Overview](#overview) * [Defining Routes](#defining-routes) * [Registering Methods](#registering-methods) * [Permission Handling](#permission-handling) * [Custom Permission Classes](#custom-permission-classes) * [Example Usage](#example-usage) * [How It Works](#how-it-works) ## Overview [Section titled “Overview”](#overview) Pollora’s API routing system is based on PHP attributes, eliminating the need to manually register REST API routes in WordPress. It consists of three key components: 1. **`#[WpRestRoute]`**: Defines the base REST route. 2. **`#[Method]`**: Specifies the HTTP method(s) for a given function. 3. **Permission Classes**: Handles user permissions before executing the request. ## Defining Routes [Section titled “Defining Routes”](#defining-routes) To create a new API route, use the `#[WpRestRoute]` attribute on a class. ```php use Pollora\Attributes\WpRestRoute; #[WpRestRoute( namespace: 'app/v2', route: 'document/(?P\\d+)' )] class DocumentAPI {} ``` ### Route Structure [Section titled “Route Structure”](#route-structure) * **`namespace`**: Defines the base namespace for the route. * **`route`**: Defines the endpoint pattern (supporting regex parameters). Once defined, WordPress will recognize the API endpoint: ```http GET wp-json/app/v2/document/18 ``` ## Registering Methods [Section titled “Registering Methods”](#registering-methods) Use the `#[Method]` attribute on methods inside the class to define HTTP methods: ```php use Pollora\Attributes\WpRestRoute\Method; use WP_REST_Request; use WP_REST_Response; class DocumentAPI { #[Method('GET')] public function get(int $documentId): WP_REST_Response { return new WP_REST_Response([ 'success' => true, 'documentId' => $documentId, ]); } #[Method(['POST', 'DELETE'])] public function delete(WP_REST_Request $request, int $documentId): WP_REST_Response { return new WP_REST_Response([ 'success' => true, 'deleted' => $documentId, ]); } } ``` ### Supported HTTP Methods [Section titled “Supported HTTP Methods”](#supported-http-methods) * `GET` * `POST` * `PUT` * `DELETE` * `PATCH` If an invalid HTTP method is provided, an exception will be thrown during route registration. ## Permission Handling [Section titled “Permission Handling”](#permission-handling) Pollora allows defining **permissions** at both the class and method level. ### Route-Level Permission [Section titled “Route-Level Permission”](#route-level-permission) Permissions can be applied globally to all methods within a class: ```php use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute; use Pollora\WpRest\Permissions\IsAdmin; #[WpRestRoute( namespace: 'app/v2', route: 'document/(?P\\d+)', permissionCallback: IsAdmin::class )] class AdminDocumentAPI {} ``` ### Method-Level Permission [Section titled “Method-Level Permission”](#method-level-permission) Permissions can also be set for specific HTTP methods: ```php use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute\Method; use WP_REST_Response; use Pollora\WpRest\Permissions\IsAdmin; use Pollora\WpRest\Permissions\IsLoggedIn; class AdminDocumentAPI { #[Method('GET', permissionCallback: IsLoggedIn::class)] public function get(): WP_REST_Response {} #[Method('DELETE', permissionCallback: IsAdmin::class)] public function delete(): WP_REST_Response {} } ``` If a method has its own permission callback, it **overrides** the class-level permission. ### Checking a capability [Section titled “Checking a capability”](#checking-a-capability) `Can` allows the user when they have a WordPress capability. Unlike `IsAdmin`, it takes arguments, so pass an instance: ```php use App\Cms\Roles\EventCap; use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute\Method; use Pollora\WpRest\Permissions\Can; #[WpRestRoute('app/v1', 'events/(?P\\d+)', permissionCallback: new Can('edit_posts'))] class EventAPI { #[Method('GET', permissionCallback: new Can(EventCap::ExportAttendees))] public function attendees(int $id): array {} #[Method('PUT', permissionCallback: new Can('edit_post', parameter: 'id'))] public function update(int $id): array {} } ``` | Parameter | Effect | | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `capability` | A capability, or a case of a [`#[CapabilitySet]`](/advanced/roles-capabilities/#project-capabilities) enum | | `parameter` | A request parameter whose value is passed with the capability, for a meta capability such as `edit_post` on the post being edited | A guest is refused with a 401 status, a logged-in user without the capability with a 403. `permissionCallback` accepts an instance of any permission class, yours included. ## Custom Permission Classes [Section titled “Custom Permission Classes”](#custom-permission-classes) A permission class must implement `Pollora\Attributes\WpRestRoute\Permission` and define an `allow()` method that returns `true`, `false`, or a `WP_Error`. ### Example: Restrict to Administrators [Section titled “Example: Restrict to Administrators”](#example-restrict-to-administrators) ```php use Pollora\Attributes\WpRestRoute\Permission; use WP_REST_Request; use WP_Error; class IsAdmin implements Permission { public function allow(WP_REST_Request $request): bool|WP_Error { return current_user_can('manage_options') ?: new WP_Error( 'rest_forbidden', __('You do not have permission to access this endpoint.'), ['status' => 403] ); } } ``` ## Example Usage [Section titled “Example Usage”](#example-usage) ```php use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute; use Pollora\Attributes\WpRestRoute\Method; use Pollora\WpRest\Permissions\IsAdmin; use WP_REST_Request; use WP_REST_Response; #[WpRestRoute( namespace: 'app/v2', route: 'document/(?P\\d+)', permissionCallback: IsAdmin::class )] class DocumentAPI { #[Method('GET')] public function get(int $documentId): WP_REST_Response { return new WP_REST_Response(['success' => true, 'documentId' => $documentId]); } #[Method(['DELETE', 'POST'])] public function delete(WP_REST_Request $request, int $documentId): WP_REST_Response { return new WP_REST_Response(['success' => true, 'deleted' => $documentId]); } } ``` ## How It Works [Section titled “How It Works”](#how-it-works) 1. **Pollora scans attributes** and detects classes annotated with `#[WpRestRoute]`. 2. **It registers API endpoints** dynamically within WordPress. 3. **Methods with `#[Method]` are linked** to the appropriate HTTP method. 4. **Permissions are validated** before executing the request. # Roles & Capabilities > Check WordPress capabilities with Laravel in Pollora — can(), the can: middleware, @can — and declare roles in code with #[Role] and #[ModifyRole]. WordPress decides what a user may do with **capabilities** (`edit_posts`, `manage_options`…), which users get through their **roles** (`editor`, `author`…). Pollora connects them to Laravel’s authorization — `$user->can()`, the `can:` middleware, `@can` in Blade — checks roles the same way (`hasRole()`, `role:`, `@role`), and lets you declare roles in code instead of storing them in the database. > **Experimental.** Declaring roles with `#[Role]`, `#[ModifyRole]` and `#[CapabilitySet]`, and checking roles with `hasRole()`, `role:` and `@role` on role classes, are new: the API may still change before it is declared stable. Checking capabilities is stable. ## Checking capabilities [Section titled “Checking capabilities”](#checking-capabilities) ### In PHP [Section titled “In PHP”](#in-php) The authenticated user is a `Pollora\Models\User`, and Laravel’s authorization answers with WordPress capabilities: ```php use Illuminate\Support\Facades\Gate; $user = auth()->user(); $user->can('edit_posts'); // WordPress capability $user->can('edit_post', $post); // meta capability, resolved by map_meta_cap() $user->cannot('manage_options'); Gate::allows('edit_post', $post); Gate::authorize('manage_options'); // throws an AuthorizationException (403) ``` Eloquent models passed as arguments (`$post` above, a `Pollora\Models\Post`) are converted to their ID, which is what WordPress expects. A guest is denied. Abilities you define yourself with `Gate::define()`, and policies, keep priority: WordPress is only asked when they do not decide. Capabilities declared in an enum ([see below](#project-capabilities)) can be passed as enum cases: `Gate::allows(EventCap::ExportAttendees)`. WordPress’s own functions keep working: `current_user_can('edit_posts')` and `user_can($userId, 'edit_posts')` give the same answers. ### In routes [Section titled “In routes”](#in-routes) ```php Route::get('/reports', ReportController::class)->middleware('can:manage_options'); ``` A user without the capability gets a 403 response. In a WordPress REST route declared with `#[WpRestRoute]`, use the `Can` permission: `permissionCallback: new Can('edit_posts')` (see [REST API](/advanced/rest-api/#checking-a-capability)). ### In Blade [Section titled “In Blade”](#in-blade) | Directive | Shows its content when | Provided by | | ----------------------------------------------------------- | --------------------------------- | --------------- | | `@can('edit_posts') … @endcan` | the user has the capability | Laravel | | `@cannot('manage_options') … @endcannot` | the user does not have it | Laravel | | `@canany(['edit_posts', 'moderate_comments']) … @endcanany` | the user has at least one of them | Laravel | | `@role('editor', EventManager::class) … @endrole` | the user has one of these roles | Pollora | | `@user … @enduser` | a user is logged in | Sage Directives | | `@guest … @endguest` | nobody is logged in | Sage Directives | `@can` accepts arguments and alternatives, like any Laravel ability: ```blade @can('edit_post', $post) Edit @endcan @can('manage_options') Administration @elsecan('export_attendees') Export attendees @else

Nothing to manage here.

@endcan ``` `@role` takes slugs, compared without regard to case, or the classes of [declared roles](#declaring-a-role) — in a view, write the class with its namespace or import it with `@use`. It replaces the directive of the same name from Sage Directives, which only took slugs. **Check a capability rather than a role.** `@can('export_attendees')` stays right when you later give that capability to a second role; `@role('event_manager')` becomes wrong. Keep `@role` for content that is about the role itself, such as a welcome message. ## Post types with their own capabilities [Section titled “Post types with their own capabilities”](#post-types-with-their-own-capabilities) By default a post type shares the capabilities of posts: anyone who can edit posts can edit its entries. To control it separately, give it its own capability type: ```php use Pollora\Attributes\PostType; use Pollora\Attributes\PostType\CapabilityType; use Pollora\Attributes\PostType\MapMetaCap; #[PostType('event')] #[CapabilityType('event')] #[MapMetaCap] class Event {} ``` WordPress then expects capabilities named after it: `edit_events`, `publish_events`, `edit_others_events`… **No role has them**, administrators included — the post type would disappear from the admin. Pollora gives them to the **super roles** automatically (see [Super roles](#super-roles)), and you grant them to other roles with [`#[GrantsPostType]`](#post-types-and-taxonomies). `#[MapMetaCap]` lets WordPress turn checks on a single entry (`edit_post` on post 42) into these capabilities; keep it with `#[CapabilityType]`. The names follow `get_post_type_capabilities()`; `#[Capabilities([...])]` renames some of them, and Pollora uses the renamed ones. See the [post type attributes reference](/content/post-types-reference/). Taxonomies work the same way: one with its own `#[Capabilities]` (`manage_terms`, `edit_terms`, `delete_terms`, `assign_terms`) stops sharing `manage_categories` with categories. ## Declaring a role [Section titled “Declaring a role”](#declaring-a-role) A role is a class carrying `#[Role]`, with attributes that grant or remove capabilities. Generate one with: ```bash php artisan pollora:make:role EventManager ``` ```php use App\Cms\PostTypes\Event; use App\Cms\Roles\EventCap; use Pollora\Attributes\Role; use Pollora\Attributes\Role\Grants; use Pollora\Attributes\Role\GrantsPostType; use Pollora\Attributes\Role\Without; use Pollora\Role\Domain\Enums\Access; #[Role('event_manager', label: 'Event manager', inherits: 'author')] #[GrantsPostType(Event::class, Access::Editor)] #[Grants(EventCap::ExportAttendees, 'moderate_comments')] #[Without('publish_posts')] final class EventManager {} ``` This role starts from the capabilities of an author, manages every event, can export attendees and moderate comments, and cannot publish posts. Like other attribute classes, it is discovered automatically in `app/`, modules, plugins and themes. | Attribute | Parameters | Effect | | ------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `#[Role]` | `slug`, `label`, `inherits`, `allowSensitive`, `textDomain` | Declares the role. `label` defaults to the class name. `inherits` names a role — a slug or the class of another `#[Role]` — whose capabilities are the starting point. `textDomain` translates the label ([see below](#translated-labels)) | | `#[Grants]` | capabilities, as strings or enum cases | Adds capabilities. Repeatable | | `#[Without]` | capabilities | Removes capabilities, typically inherited ones. Repeatable | | `#[GrantsPostType]` | post type class or slug, `Access` level | Adds the capabilities of a post type with its own capability type. Repeatable | | `#[GrantsTaxonomy]` | taxonomy class or slug | Adds the term capabilities of a taxonomy with its own capabilities. Repeatable | Inheritance follows the parent as it is on each request: when a plugin adds a capability to `author`, `event_manager` gets it too. ### Translated labels [Section titled “Translated labels”](#translated-labels) WordPress looks for role names in its own catalogue only. Give the label your theme’s or plugin’s text domain to translate it from your catalogue: ```php #[Role('event_manager', label: 'Event manager', textDomain: 'my-theme')] final class EventManager {} ``` The label is then translated wherever WordPress shows role names — users list, role dropdowns, profile screen. Add `Event manager` to your `.po` file as a plain string, without context: `__('Event manager', 'my-theme')` in a file the extraction tool scans is enough to collect it. Translation happens when the admin displays the role, so it never loads your catalogue too early. ### Post types and taxonomies [Section titled “Post types and taxonomies”](#post-types-and-taxonomies) `#[GrantsPostType]` takes an access level, named after the core role that has it on posts. Each level includes the previous ones. For the `event` capability type: | Level | The user can | Capabilities added | | --------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `Access::Contributor` | create and edit their own drafts, without publishing | `edit_events`, `delete_events` | | `Access::Author` | publish and manage their own entries | + `publish_events`, `edit_published_events`, `delete_published_events` | | `Access::Editor` | manage everyone’s entries, private ones included | + `edit_others_events`, `delete_others_events`, `read_private_events`, `edit_private_events`, `delete_private_events` | Pass the post type’s class when you can: Pollora checks it at once and refuses a post type that has no `#[CapabilityType]`, with the attribute to add. A slug is resolved later, which also covers a post type declared in a theme or a plugin; one that cannot be found is skipped and logged. ## Modifying an existing role [Section titled “Modifying an existing role”](#modifying-an-existing-role) A role the project does not own — `editor`, WooCommerce’s `shop_manager`, a plugin’s role — is adjusted with `#[ModifyRole]`, without redefining it: ```php use Pollora\Attributes\ModifyRole; use Pollora\Attributes\Role\Grants; use Pollora\Attributes\Role\GrantsPostType; use Pollora\Attributes\Role\Without; use Pollora\Role\Domain\Enums\Access; #[ModifyRole('editor')] #[GrantsPostType(Event::class, Access::Editor)] #[Without('edit_theme_options')] final class EditorAdjustments {} ``` Several classes may modify the same role, for instance one per module. If one grants a capability another removes, discovery refuses the second and names both. ## Project capabilities [Section titled “Project capabilities”](#project-capabilities) Capabilities your own code checks are best declared in a backed enum marked `#[CapabilitySet]`: ```php use Pollora\Attributes\CapabilitySet; #[CapabilitySet(label: 'Events')] enum EventCap: string { case ExportAttendees = 'export_attendees'; case ScanTickets = 'scan_tickets'; case RefundTickets = 'refund_tickets'; } ``` You grant them as enum cases (`#[Grants(EventCap::ScanTickets)]`), check them the same way (`Gate::allows(EventCap::RefundTickets)`), and the super roles receive all of them — including a capability no role is granted yet, which only administrators then have. ## Super roles [Section titled “Super roles”](#super-roles) The roles listed in `roles.super_roles` — `administrator` by default — receive every capability the project declares: those of post types with `#[CapabilityType]`, of taxonomies with their own capabilities, and of `#[CapabilitySet]` enums. To change the list, create `config/roles.php` in your project: ```php return [ 'super_roles' => ['administrator', 'shop_manager'], ]; ``` A `#[Role]` cannot inherit from a super role. ## How roles reach WordPress [Section titled “How roles reach WordPress”](#how-roles-reach-wordpress) WordPress stores roles in the database (the `{prefix}user_roles` option), and `add_role()` writes them once, then does nothing on later calls. Pollora does not write declared roles there: it puts them into WordPress’s role registry **in memory, on every request**, when WordPress builds it (the `wp_roles_init` action). * **The code is the only source of truth.** Change a class and deploy: the role follows, on every environment, with nothing to migrate. Revert the commit and the previous rights are back. * **Removed from the code means gone.** A plugin that calls `add_cap()` makes WordPress write all in-memory roles back to the database, declared ones included. Pollora marks what it adds, and undoes it on the next request if the declaration is no longer there: the role disappears, a capability granted by a `#[ModifyRole]` is removed, and one it removed is restored. Users who had a removed role keep its slug but get no capability from it. * **Role editor plugins** cannot change a declared role: the code wins on every request. * **Multisite:** roles are injected again on each site switch. * **Plugins and themes:** their roles are discovered while WordPress loads them, after it has built its roles; Pollora injects them into the existing registry and recomputes the current user’s capabilities, so they apply to the request that is running. A tool that reads the `user_roles` option directly, without loading the site, does not see declared roles. ## Inspecting roles [Section titled “Inspecting roles”](#inspecting-roles) `php artisan pollora:roles:list` lists the roles WordPress has once the code is applied, with where each comes from — declared by a class, stored and modified by a `#[ModifyRole]`, or stored as it is — its number of capabilities and the number of users who carry it. `php artisan pollora:roles:show event_manager` (or `EventManager::class`) lists the effective capabilities of a role and where each comes from, then what the code removes: ```plaintext event_manager Event manager ............................ App\Cms\Roles\EventManager inherits ............................................................. author +------------------------+---------------------------+ | Capability | From | +------------------------+---------------------------+ | edit_events | granted by EventManager | | edit_posts | inherited from author | | export_attendees | granted by EventManager | | ... | | +------------------------+---------------------------+ INFO Removed by the code: publish_posts. ``` Both take `--json`. `php artisan pollora:doctor`, and **Tools › Site Health** in wp-admin, check what no error ever shows: * **Users carrying a role removed from the code.** They keep its slug and get no capability from it: the check names the role and the users. Give them another role. * **A `default_role` naming a role that no longer exists**: every new user would get no capability. * **Capabilities given to users one by one**, outside the roles: they live in the database, not in the code. * **What a declaration could not apply**, such as a post type grant whose post type has no capabilities of its own (until now only logged). ## Cleaning up and migrating [Section titled “Cleaning up and migrating”](#cleaning-up-and-migrating) These commands change the database. Each shows what it would do and changes nothing unless it is run again with `--force`. **`php artisan pollora:roles:prune --reassign=subscriber`** cleans up after roles removed from the code: it takes the dead role off the users who still carry it, gives those left with no role the `--reassign` one, and deletes the copies of removed roles a plugin wrote back to the database. Without `--reassign`, it refuses to leave a user with no role. ```plaintext jane ................................... remove event_manager → subscriber joe ................................................ remove event_manager stored role "event_manager" ......... delete the copy a plugin wrote back WARN Dry run: nothing changed. Run again with --force to apply. ``` **`php artisan pollora:roles:import venue_staff --inherits=author`** turns a role stored in the database — made by `add_role()` or a role editor plugin — into a `#[Role]` class, in `app/Cms/Roles` (or `--theme`, `--plugin`, `--module`). With `--inherits`, the class only holds the differences: ```php #[Role('venue_staff', label: 'Venue staff', inherits: 'author')] #[Grants( 'scan_tickets', )] #[Without( 'publish_posts', )] final class VenueStaff { } ``` Review it before committing: a sensitive capability gets `allowSensitive: true` and a note, and a capability stored as denied (`false`) is listed in the docblock and left out, since a role only grants or removes. Core roles are refused: change them with `#[ModifyRole]`. Once the class is deployed, the code owns the role; the stored copy is ignored. **`php artisan pollora:roles:dump`** writes the roles as the code makes them into the `{prefix}user_roles` option, for a tool that reads the database without loading the site. The code stays the source of truth: roles are still injected on every request, and what the command writes is marked, so a role later removed from the code is still removed. ## Safety rules [Section titled “Safety rules”](#safety-rules) Distributing rights is easy to get wrong, so discovery refuses a declaration that would grant the wrong rights — the error is logged with the class named, and that declaration is not applied: * **Sensitive capabilities** — `manage_options`, `edit_users`, `create_users`, `delete_users`, `promote_users`, `unfiltered_html`, `unfiltered_upload`, `install_plugins`, `activate_plugins`, `edit_plugins`, `edit_themes`, `edit_files`, `update_core` — can only be granted with `allowSensitive: true` on `#[Role]` or `#[ModifyRole]`. The flag makes the choice visible in code review. * **Core roles** (`administrator`, `editor`, `author`, `contributor`, `subscriber`) cannot be redeclared with `#[Role]`; use `#[ModifyRole]`. * **No inheritance from a super role**, no inheritance loop, no slug declared by two classes. * **A capability both granted and removed** by the same class is refused. * **`#[Without]` removes a capability; it never sets it to `false`.** WordPress merges the roles of a user, and an explicit denial in one role would override or be overridden by another depending on their order. ## Checking roles [Section titled “Checking roles”](#checking-roles) A role is named by its slug (`'editor'`) or by the class of a `#[Role]` (`EventManager::class`), which the IDE can follow and rename. ### In PHP [Section titled “In PHP”](#in-php-1) `Pollora\Models\User` has these methods: ```php $user = auth()->user(); $user->roles(); // ['author', 'event_manager'] $user->hasRole(EventManager::class); // true $user->hasRole('editor', 'administrator'); // true if the user has one of them $user->assignRole(EventManager::class); // adds the role, keeping the others $user->removeRole('event_manager'); ``` `assignRole()` and `removeRole()` go through `WP_User`, so WordPress’s hooks (`add_user_role`, `remove_user_role`) and user cache follow. `assignRole()` refuses a role WordPress does not know; `removeRole()` also removes a role that no longer exists. **Neither checks the rights of the code calling them**, like `WP_User::set_role()`: where a user can trigger them, check `promote_users` first. Your own user model gets the same methods with the `Pollora\Models\Concerns\HasRoles` trait, provided it has a `toWpUser(): WP_User` method. ### In routes [Section titled “In routes”](#in-routes-1) ```php use Pollora\Role\Infrastructure\Middleware\EnsureUserHasRole; Route::middleware('role:event_manager,editor')->group(function () { // … }); Route::get('/events/scan', ScanController::class) ->middleware(EnsureUserHasRole::using(EventManager::class)); ``` A user who has none of the roles, or a guest, gets a 403 response. If your application already uses the `role` alias, for another package, Pollora keeps it: use `EnsureUserHasRole::using()`. As in Blade, prefer `can:` with a capability: `can:export_attendees` stays right when a second role is given that capability, `role:event_manager` does not. # Scheduling > Schedule recurring WordPress tasks in Pollora with PHP attributes: the Every enum, custom intervals, hook names and arguments, without WP-Cron boilerplate. The Schedule attribute provides an elegant way to create and manage WordPress cron jobs. It allows you to easily schedule recurring tasks in your WordPress application using multiple approaches: predefined schedules, enum values, or custom intervals. ## Basic Usage [Section titled “Basic Usage”](#basic-usage) To schedule a recurring task, simply add the `Schedule` attribute to your method: ```php use Pollora\Attributes\Schedule; class Maintenance { #[Schedule('daily')] public function cleanupDatabase(): void { // This method will run once per day // The hook name will be: maintenance_cleanup_database } } ``` ## Using the Every Enum (Recommended) [Section titled “Using the Every Enum (Recommended)”](#using-the-every-enum-recommended) The framework provides an `Every` enum for type-safe scheduling with predefined intervals: ```php use Pollora\Attributes\Schedule; use Pollora\Schedule\Every; class Maintenance { #[Schedule(Every::HOUR)] public function pingSlack(): void { // Runs every hour } #[Schedule(Every::DAY)] public function dailyBackup(): void { // Runs once per day } #[Schedule(Every::WEEK)] public function weeklyReport(): void { // Runs once per week } #[Schedule(Every::MONTH)] public function monthlyCleanup(): void { // Runs once per month (custom schedule automatically registered) } #[Schedule(Every::YEAR)] public function yearlyArchive(): void { // Runs once per year (custom schedule automatically registered) } } ``` ### Available Every Options [Section titled “Available Every Options”](#available-every-options) * `Every::HOUR` - Runs once every hour (uses WordPress `hourly`) * `Every::TWICE_DAILY` - Runs twice per day (uses WordPress `twicedaily`) * `Every::DAY` - Runs once per day (uses WordPress `daily`) * `Every::WEEK` - Runs once per week (uses WordPress `weekly`) * `Every::MONTH` - Runs once per month (automatically creates custom schedule) * `Every::YEAR` - Runs once per year (automatically creates custom schedule) ## Built-in Schedule Intervals (Legacy) [Section titled “Built-in Schedule Intervals (Legacy)”](#built-in-schedule-intervals-legacy) WordPress provides several default scheduling intervals that you can still use with strings: * `hourly` - Runs once every hour * `twicedaily` - Runs twice per day * `daily` - Runs once per day * `weekly` - Runs once per week ## Custom Intervals with Interval Class [Section titled “Custom Intervals with Interval Class”](#custom-intervals-with-interval-class) For precise timing control, use the `Interval` class which provides a fluent API: ```php use Pollora\Attributes\Schedule; use Pollora\Schedule\Interval; class Maintenance { #[Schedule(new Interval(hours: 3, minutes: 30))] public function checkFeeds(): void { // Runs every 3 hours and 30 minutes } #[Schedule(new Interval( days: 1, hours: 12, minutes: 15, display: 'Daily plus 12h15m' ))] public function complexTask(): void { // Runs every 1 day, 12 hours, and 15 minutes } #[Schedule(new Interval(weeks: 2, display: 'Bi-weekly'))] public function biweeklyTask(): void { // Runs every 2 weeks } } ``` ### Interval Constructor Parameters [Section titled “Interval Constructor Parameters”](#interval-constructor-parameters) * `seconds: int = 0` - Additional seconds * `minutes: int = 0` - Additional minutes * `hours: int = 0` - Additional hours * `days: int = 0` - Additional days * `weeks: int = 0` - Additional weeks * `display: string = 'Custom schedule'` - Human-readable description ## Custom Scheduling (Legacy Array Syntax) [Section titled “Custom Scheduling (Legacy Array Syntax)”](#custom-scheduling-legacy-array-syntax) For backward compatibility, you can still create custom schedules using arrays: ```php class Maintenance { #[Schedule( ['interval' => 3600 * 6, 'display' => '6 hours'] )] public function performCheck(): void { // Runs every 6 hours } } ``` The custom schedule array requires: * `interval`: Time in seconds between executions * `display`: Human-readable name for the schedule ## Custom Hook Names [Section titled “Custom Hook Names”](#custom-hook-names) By default, the hook name is generated from the class and method names, but you can specify a custom hook name: ```php use Pollora\Schedule\Every; use Pollora\Schedule\Interval; class Maintenance { // With string schedule #[Schedule('hourly', hook: 'custom_maintenance_hook')] public function performTask(): void { // Uses 'custom_maintenance_hook' instead of 'maintenance_perform_task' } // With Every enum #[Schedule(Every::DAY, hook: 'daily_cleanup')] public function cleanup(): void { // Uses 'daily_cleanup' hook name } // With Interval class #[Schedule(new Interval(hours: 6), hook: 'six_hourly_check')] public function checkSystem(): void { // Uses 'six_hourly_check' hook name } } ``` ## Schedule with Arguments [Section titled “Schedule with Arguments”](#schedule-with-arguments) You can pass additional arguments to your scheduled function with any schedule type: ```php use Pollora\Schedule\Every; use Pollora\Schedule\Interval; class Maintenance { // With string schedule #[Schedule( 'daily', hook: 'cleanup_task', args: ['type' => 'full', 'force' => true] )] public function cleanup(array $args): void { $type = $args['type']; // 'full' $force = $args['force']; // true // Perform cleanup based on arguments } // With Every enum and arguments #[Schedule(Every::DAY, hook: 'custom_hook', args: ['type' => 'full'])] public function updateIndex(array $args): void { // Process with arguments } // With Interval class and arguments #[Schedule( new Interval(hours: 2, minutes: 30), hook: 'data_sync', args: ['source' => 'api', 'batch_size' => 100] )] public function syncData(array $args): void { // Sync data with specified parameters } } ``` ## All-in-One Example [Section titled “All-in-One Example”](#all-in-one-example) Here’s a comprehensive example showing various ways to use the Schedule attribute: ```php use Pollora\Attributes\Schedule; use Pollora\Schedule\Every; use Pollora\Schedule\Interval; class SystemMaintenance { // Using Every enum (recommended) #[Schedule(Every::DAY)] public function dailyCleanup(): void { // Daily cleanup using enum } // Custom interval using Interval class #[Schedule(new Interval(hours: 4, display: '4 hours'))] public function systemHealthCheck(): void { // Runs every 4 hours } // Monthly schedule with custom hook (auto-registered) #[Schedule(Every::MONTH, hook: 'monthly_reports')] public function generateReports(): void { // Monthly report generation } // Complex interval with arguments #[Schedule( new Interval(hours: 2, minutes: 30), hook: 'data_sync', args: ['type' => 'incremental', 'source' => 'api'] )] public function syncData(array $args): void { // Runs every 2.5 hours with arguments } // Legacy string syntax (still supported) #[Schedule('twicedaily')] public function legacyTask(): void { // Backward compatibility } // Legacy array syntax (still supported) #[Schedule( ['interval' => 3600 * 6, 'display' => '6 hours'], hook: 'legacy_check' )] public function legacyCustomTask(): void { // Backward compatibility with arrays } } ``` ## Schedule Type Summary [Section titled “Schedule Type Summary”](#schedule-type-summary) | Type | Syntax | Use Case | Custom Registration | | ------------------ | ------------------------ | ------------------------------- | ------------------- | | **Every Enum** | `Every::DAY` | Type-safe, predefined intervals | Auto for MONTH/YEAR | | **Interval Class** | `new Interval(hours: 2)` | Precise custom timing | Always | | **String** | `'daily'` | WordPress built-ins | Never | | **Array** | `['interval' => 3600]` | Legacy custom intervals | Always | ## Important Notes [Section titled “Important Notes”](#important-notes) 1. **Hook names are automatically generated in snake\_case format if not specified:** * For a class `DatabaseMaintenance` with method `cleanupOldRecords` * The generated hook name would be `database_maintenance_cleanup_old_records` 2. **The Schedule attribute ensures that events are only registered once** through automatic discovery. 3. **Custom schedules are automatically registered** when using: * `Every::MONTH` and `Every::YEAR` enum values * `Interval` class instances * Legacy array syntax with custom intervals 4. **All syntax variations support the same parameters:** * `hook: string` - Custom hook name * `args: array` - Arguments passed to the scheduled method # WP-CLI Commands > Create custom WP-CLI commands in Pollora with PHP attributes: single commands, subcommand suites, automatic slugs, and a generator to scaffold them. The Pollora framework provides a declarative way to create WordPress CLI commands using PHP attributes. Commands are automatically discovered and registered with intelligent slug auto-generation. ## Table of Contents [Section titled “Table of Contents”](#table-of-contents) * [Quick Start](#quick-start) * [Single Commands](#single-commands) * [Command Suites (Subcommands)](#command-suites-subcommands) * [Automatic Slug Generation](#automatic-slug-generation) * [WP-CLI Attributes](#wp-cli-attributes) * [Command Generator](#command-generator) * [Examples](#examples) * [Best Practices](#best-practices) ## Quick Start [Section titled “Quick Start”](#quick-start) ```php * : The username for the new user * * [--role=] * : The user role * --- * default: subscriber * --- * * ## EXAMPLES * * wp user-create john --role=editor */ class UserCreateCommand { public function __invoke(array $arguments, array $options): void { $username = $arguments[0] ?? null; $role = $options['role'] ?? 'subscriber'; if (!$username) { WP_CLI::error('Username is required.'); return; } WP_CLI::success("User '{$username}' created with role '{$role}'"); } } ``` ## Command Suites (Subcommands) [Section titled “Command Suites (Subcommands)”](#command-suites-subcommands) WP-CLI automatically registers all **public methods** as subcommands. Method names are converted to kebab-case. ```php [--greeting=] [--uppercase]')] class GreetCommand { public function __invoke(array $arguments, array $options): void { $name = $arguments[0]; $greeting = $options['greeting'] ?? 'Hello'; WP_CLI::success("{$greeting}, {$name}!"); } } ``` ### #\[When] [Section titled “#\[When\]”](#when) Control when the command is available: ```php use Pollora\Attributes\WpCli\When; #[WpCli] #[When('after_wp_load')] // Execute after WordPress loads (most common) class MyCommand { } #[WpCli] #[When('before_wp_load')] // Execute before WordPress loads class EarlyCommand { } ``` ### #\[BeforeInvoke] / #\[AfterInvoke] [Section titled “#\[BeforeInvoke\] / #\[AfterInvoke\]”](#beforeinvoke--afterinvoke) Execute callbacks before/after the command: ```php use Pollora\Attributes\WpCli\BeforeInvoke; use Pollora\Attributes\WpCli\AfterInvoke; #[WpCli] #[BeforeInvoke([self::class, 'setup'])] #[AfterInvoke([self::class, 'cleanup'])] class MyCommand { public function __invoke(array $arguments, array $options): void { WP_CLI::success('Command executed'); } public static function setup(): void { WP_CLI::debug('Setting up...'); } public static function cleanup(): void { WP_CLI::debug('Cleaning up...'); } } ``` ### #\[IsDeferred] [Section titled “#\[IsDeferred\]”](#isdeferred) Control registration timing: ```php use Pollora\Attributes\WpCli\IsDeferred; #[WpCli] #[IsDeferred(false)] // Register immediately (default is true) class MyCommand { } ``` ### Complete Example [Section titled “Complete Example”](#complete-example) ```php [--greeting=] [--format=]')] #[BeforeInvoke([self::class, 'validate'])] /** * Greet someone with style * * ## EXAMPLES * * wp greet John * wp greet John --greeting="Bonjour" */ class GreetCommand { public function __invoke(array $arguments, array $options): void { $name = $arguments[0]; $greeting = $options['greeting'] ?? 'Hello'; WP_CLI::success("{$greeting}, {$name}!"); } public static function validate(): void { WP_CLI::debug('Validating input...'); } } ``` ## Command Generator [Section titled “Command Generator”](#command-generator) Generate command classes with Artisan: ```bash # Basic command php artisan pollora:make:wp-cli TestCommand --description="Test command" # In a theme php artisan pollora:make:wp-cli ThemeCommand --theme=mytheme --description="Theme utilities" # In a plugin php artisan pollora:make:wp-cli PluginCommand --plugin=myplugin --description="Plugin tools" ``` ### Options [Section titled “Options”](#options) | Option | Description | | ------------------- | ------------------------------ | | `--description, -d` | Command description (required) | | `--force, -f` | Overwrite existing files | | `--theme` | Generate in specific theme | | `--plugin` | Generate in specific plugin | | `--module` | Generate in specific module | | `--path` | Custom generation path | ## Examples [Section titled “Examples”](#examples) ### Database Cleanup [Section titled “Database Cleanup”](#database-cleanup) ```php 'spam', 'count' => true]); if (!$dryRun && $spamCount > 0) { global $wpdb; $wpdb->delete($wpdb->comments, ['comment_approved' => 'spam']); } WP_CLI::success("Cleaned {$spamCount} spam comments"); } } ``` ### Cache Management Suite [Section titled “Cache Management Suite”](#cache-management-suite) ```php 10]); WP_CLI::success('Cache warmed up'); } } ``` ```bash wp cache flush wp cache warmup ``` ## Best Practices [Section titled “Best Practices”](#best-practices) ### Error Handling [Section titled “Error Handling”](#error-handling) ```php public function __invoke(array $arguments, array $options): void { try { $this->doSomething(); WP_CLI::success('Done!'); } catch (\Exception $e) { WP_CLI::error($e->getMessage()); } } ``` ### Input Validation [Section titled “Input Validation”](#input-validation) ```php public function __invoke(array $arguments, array $options): void { if (empty($arguments[0])) { WP_CLI::error('First argument is required.'); return; } } ``` ### Progress Bars [Section titled “Progress Bars”](#progress-bars) ```php public function __invoke(array $arguments, array $options): void { $items = $this->getItems(); $progress = \WP_CLI\Utils\make_progress_bar('Processing', count($items)); foreach ($items as $item) { $this->process($item); $progress->tick(); } $progress->finish(); } ``` ### Dry Run Support [Section titled “Dry Run Support”](#dry-run-support) ```php public function __invoke(array $arguments, array $options): void { $dryRun = isset($options['dry-run']); foreach ($items as $item) { if ($dryRun) { WP_CLI::log("Would delete: {$item->title}"); } else { $this->delete($item); } } } ``` ### Service Injection [Section titled “Service Injection”](#service-injection) ```php class MyCommand { public function __construct( private MyService $service ) {} public function __invoke(array $arguments, array $options): void { $this->service->doSomething(); } } ``` ## Discovery Locations [Section titled “Discovery Locations”](#discovery-locations) Commands are automatically discovered in: * `app/Cms/Commands/` * `themes/{theme}/app/Cms/Commands/` * `plugins/{plugin}/app/Cms/Commands/` # Block Bindings > Fill core blocks and Blade blocks with server data in Pollora: #[BlockBinding] sources in PHP, typed meta formatted by type, a field picker and live preview in the editor. A Block Binding fills an attribute of a block — the text of a paragraph, the URL of a button, the image of an image block — with a value computed on the server, instead of what the editor typed. An event page, a product sheet or a team member card can then be built from core blocks, with no custom block to write. Block Bindings need WordPress 6.9 or later; Pollora installs WordPress 7. Pollora lets you declare a source as a PHP class, ships sources that read [typed meta](/content/typed-meta/) formatted by their type, and makes your own Blade blocks bindable with one line of `block.json`. > **Experimental.** The API may still change before it is declared stable. ## Declaring a source [Section titled “Declaring a source”](#declaring-a-source) A source is a class marked `#[BlockBinding]`. Each public method marked `#[BindingField]` is a field, chosen in the block with the `field` argument: ```php use App\Cms\PostTypes\Event; use App\Services\BookingRepository; use Pollora\Attributes\BlockBinding; use Pollora\Attributes\BlockBinding\BindingField; use Pollora\BlockBinding\Domain\Models\BindingContext; #[BlockBinding('acme/event', label: 'Event', postTypes: 'event')] final class EventBinding { #[BindingField(label: 'Remaining seats')] public function remainingSeats(BindingContext $context, BookingRepository $bookings): string { $event = $context->meta(Event::class); $left = max(0, $event->capacity - $bookings->countFor($context->postId)); return trans_choice('events.seats', $left, ['count' => $left]); } #[BindingField(label: 'Booking link', type: 'url')] public function bookingUrl(BindingContext $context): string { return route('events.book', ['event' => $context->postId]); } } ``` The class is discovered in the application, themes, plugins and modules, and resolved by the container: the constructor and each field receive their dependencies, like `BookingRepository` above. `php artisan pollora:make:binding EventBinding` generates one (`--theme`, `--plugin`, `--module` to choose where). A block binds an attribute to a field in its markup: ```html

Seats available

``` The text saved in the block is a fallback: it shows when the field returns `null`. ## In the editor [Section titled “In the editor”](#in-the-editor) Nothing to write in JavaScript: the fields come from the class. * **Choosing a field.** Select a bindable block, open **Attributes** in the block settings, pick the attribute, then a source and one of its fields. A source limited by `postTypes` is only offered on those post types; `pollora/post-meta` offers the meta of the post type being edited that it may show. * **Previewing the value.** A bound block shows the value of the post being edited, computed on the server by the same code as the page, and escaped the same way. The values of the blocks on screen are asked in one request, to `POST /wp-json/pollora/v1/block-bindings/resolve`, which answers only a user who can edit the post. * **Read-only.** A block bound to a Pollora source cannot be edited from the canvas. While its value loads, and when the field gives none, it shows the field’s label. To edit a meta from a block, bind it to WordPress’s `core/post-meta`. The editor offers a field to an attribute of the same data type: every field gives a string, so it is offered to text and URL attributes alike, and an attachment meta is also offered to an image’s `id`. ### Parameters [Section titled “Parameters”](#parameters) | Attribute | Parameter | Default | Effect | | ----------------- | ------------- | -------------------------- | ------------------------------------------------------------------------------------- | | `#[BlockBinding]` | `name` | required | Source name, `namespace/name` in lowercase, as WordPress requires | | `#[BlockBinding]` | `label` | class name | Label shown in the editor | | `#[BlockBinding]` | `usesContext` | `['postId', 'postType']` | Block context the source reads | | `#[BlockBinding]` | `postTypes` | all | The post types the source answers for; on any other post, the block keeps its content | | `#[BindingField]` | `name` | method name in snake\_case | Value of the `field` argument: `remainingSeats` is `remaining_seats` | | `#[BindingField]` | `label` | method name, headlined | Label of the field | | `#[BindingField]` | `type` | `text` | `text`, `url` or `image`: a `url` or `image` value is sanitized as a URL | A class with no field defines `__invoke(BindingContext $context)` and receives every call, whatever its arguments. ### `BindingContext` [Section titled “BindingContext”](#bindingcontext) | Member | Content | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `postId`, `postType` | The post of the block context, so the right one inside a query loop | | `termId`, `taxonomy` | The term of the block context (a terms query) | | `arg('format', $default)` | An argument of the binding, besides `field` | | `attribute` | The bound attribute: `content`, `url`, `alt`… | | `block` | The `WP_Block`, for advanced cases | | `post()` | The post, as a `Pollora\Models\Post` (the model bound to its post type, when there is one) | | `meta(Event::class)` | The [typed meta](/content/typed-meta/) of a class, on the object that carries them: the post, the term, or the post’s author for `#[UserMeta]` | ### What a field returns [Section titled “What a field returns”](#what-a-field-returns) A field declares its return type: `string`, `int`, `float`, `bool`, a `Stringable` or `null`. A class whose field has no return type, or returns an array, is reported at discovery, like a name WordPress would refuse or a field declared twice. Pollora escapes the value for the place it lands in, so a source cannot forget to: | Value | Written as | | ------------------------------------------- | -------------------------------------------------------- | | Text in a paragraph, a heading, a list item | Escaped: `` shows as text | | A `url` or `image` field | Sanitized as a URL | | An `HtmlString` | Filtered like post content (`wp_kses_post()`) | | A boolean in a paragraph | ”Yes” or “No”, translated | | An attribute of a Blade block | As returned: the template escapes it, like any attribute | `null` keeps what the block holds. A field that throws is logged and treated as `null`, so a failing source never takes a page down; with `APP_DEBUG` on, the exception is thrown. With `APP_DEBUG` on too, a field that takes more than 50 ms is logged as a warning, with its source, its field and its post: every bound block of the page waits for it. ## Bindable Blade blocks [Section titled “Bindable Blade blocks”](#bindable-blade-blocks) A block rendered on the server becomes bindable by listing its attributes under `pollora.bindings` in its `block.json`: ```json { "name": "acme/event-card", "attributes": { "title": { "type": "string" }, "ctaUrl": { "type": "string" } }, "pollora": { "bindings": ["title", "ctaUrl"] }, "render": "file:./render.blade.php" } ``` WordPress replaces the bound attributes before the block renders, so the view does not change: ```blade

{{ $attributes['title'] }}

{{ __('Book', 'acme') }}
``` Only a block with a `render` can be bound: WordPress cannot rewrite the saved HTML of a block that is not one of its own. A block listing bindings without `render`, or an attribute it does not declare, is reported in the log. ## Typed meta in core blocks [Section titled “Typed meta in core blocks”](#typed-meta-in-core-blocks) Pollora ships four sources. Those reading meta format the value by its declared type, which `core/post-meta` does not: a date reads as a date, not `2026-11-14T09:00:00+00:00`. | Source | Reads | Arguments | | --------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `pollora/post-meta` | A typed meta of the post of the block | `key`, `format`, `decimals`, `true`, `false`, `size`, `fallback` | | `pollora/term-meta` | A typed meta of the term of the block, or of the queried term on a term archive | same | | `pollora/author-meta` | A typed meta of the author of the post | same | | `pollora/option` | A site option | `name`, `fallback` | ```html

Date to come

``` The title of a post, its link or date, and the name of a term are served by WordPress’s own `core/post-data` and `core/term-data`. ### Formatting [Section titled “Formatting”](#formatting) | Meta type | Shown as | Argument | | -------------- | ---------------------------------------------------------- | ------------------------------- | | `string` | As stored, escaped | none | | `int`, `float` | A number with the site’s separators | `decimals` | | `bool` | ”Yes” or “No”, translated | `true`, `false` for other words | | Date | The site’s date format, in its language | `format`, a PHP date format | | Enum | Its `label()` when the enum defines one, else its value | none | | Array | Its items, as a list: “rock, jazz, and blues” | as for one item | | Attachment ID | Depends on the bound attribute ([see below](#attachments)) | `size` | `format: raw` gives the value as stored, and `fallback` a text for an empty meta. An object meta is not shown. ### Attachments [Section titled “Attachments”](#attachments) A meta marked `#[Meta(media: true)]` holds an attachment ID. Bound to an image, it gives what each attribute needs: ```php #[Meta(showInRest: true, media: true)] public ?int $coverImageId = null; ``` ```html
``` | Attribute | Value | | --------------------- | ------------------------------------------- | | `url` (and any other) | The image URL at `size` (`full` by default) | | `alt` | The alternative text | | `title` | The attachment title | | `caption` | The caption | | `id` | The ID | ## Checking the bindings [Section titled “Checking the bindings”](#checking-the-bindings) A binding that cannot show its value gives no error: the block simply keeps the content it was saved with. `php artisan pollora:doctor`, and **Tools › Site Health** in wp-admin, read every binding written in the templates, template parts and patterns of the theme, the Pollora plugins and the modules, and name the file, the block and the reason: ```plaintext ✗ Block bindings — 2 binding(s) can never show a value: the blocks keep their saved content. theme buzz: templates/single-event.html — core/paragraph, "content" → acme/event: the field "seats" does not exist; acme/event has the fields "remaining_seats", "booking_url" theme buzz: patterns/event-card.php — core/paragraph, "content" → pollora/post-meta: the meta "internal_ref" is never shown: it is not exposed in REST (showInRest: true) or its key is protected ``` It reports a source that is not registered, a field the source does not have, a meta no `#[Meta]` declares, a meta or an option the source may not show, an attribute WordPress does not bind for that block, and a `block.json` whose `pollora.bindings` has no `render` or lists an attribute the block does not declare. Bindings saved in posts, in the database, are not read. `php artisan pollora:binding:list` shows what can be bound: each Pollora source with its fields, the meta it may show (by post type or taxonomy) and the options it may read, then every block whose attributes WordPress lets bind. `--json` gives the same as JSON. ```plaintext acme/event Event ..................................... EventBinding · event field: remaining_seats ............................... Remaining seats field: booking_url ...................................... Booking link pollora/post-meta Post meta (Pollora) ..................... PostMetaSource key: starts_at ............................................ Start (event) ``` ## Security [Section titled “Security”](#security) Anyone who can edit a post can bind any source to it, so each source only shows what may be public, and the checks live in one place that no source can skip: * **The post or term must be visible.** Nothing is shown of a post the visitor cannot read (unpublished without `read_post`, waiting for its password) or of a term of a taxonomy that is not publicly queryable — the checks of `core/post-meta` and `core/term-data`. * **Post and term meta**: only a meta declared with `showInRest: true`, under a key that does not start with `_`, as `core/post-meta` does. * **User meta**: `showInRest` is not enough, a user meta may be personal. `pollora/author-meta` only reads a meta marked `#[Meta(public: true)]`. * **Options**: `pollora/option` only reads the options listed in `config/block-bindings.php`, none by default: config/block-bindings.php ```php return [ 'options' => ['blogdescription'], ]; ``` * **No source reads the logged-in visitor**: the value would leak through the page cache. ## Performance [Section titled “Performance”](#performance) A field is called once per request for the same post, attribute and arguments, and a source class is only made the first time one of its fields is used. Typed meta are read through WordPress’s meta cache, already primed for the posts of a query loop. In a loop of 20 posts, four bound blocks make 80 calls: a field should not run an uncached query. # Gutenberg Blocks > Build custom Gutenberg blocks in Pollora with Vite and JSX, scaffold them with pollora:make:block, and render them on the server with Blade templates. Pollora provides a complete system for building custom Gutenberg blocks with Vite and JSX. Blocks can live in themes, plugins, or Laravel modules — the registration works identically across all three. ## How It Works [Section titled “How It Works”](#how-it-works) The `BlockRegistrar` service scans a directory for subdirectories containing `block.json`, then: 1. Creates a dedicated `{parent}.blocks` asset container with no `basePath` for direct Vite manifest resolution 2. Pre-registers each `file:./` asset (scripts and styles) via `wp_register_script` / `wp_register_style` with the Vite-compiled URLs 3. Adds `type="module"` and `crossorigin` attributes for Vite scripts 4. Calls `register_block_type()` — WordPress finds the pre-registered handles and skips its own resolution ## Quick Start [Section titled “Quick Start”](#quick-start) ### 1. Scaffold a Block [Section titled “1. Scaffold a Block”](#1-scaffold-a-block) ```bash php artisan pollora:make:block hero-banner --theme ``` This creates all the files in `resources/views/blocks/hero-banner/` and bootstraps the Vite infrastructure on first use (vite.config.js patching, npm dependencies). No service provider is written: Pollora registers the blocks of every theme, plugin and module itself (see [Registration](#registration)). ### 2. Build [Section titled “2. Build”](#2-build) ```bash cd themes/your-theme npm install npm run build # or npm run dev for HMR ``` ### 3. Done [Section titled “3. Done”](#3-done) The block appears in the Gutenberg inserter. No manual `register_block_type()` needed. ## Block Structure [Section titled “Block Structure”](#block-structure) Each block lives in its own directory under `resources/views/blocks/`, next to the other Blade views: ```plaintext resources/views/blocks/hero-banner/ ├── block.json # WordPress block metadata ├── render.blade.php # Server-side render (default) ├── index.jsx # Entry point — registers the block ├── edit.jsx # Editor component — shows what the page will show ├── save.jsx # Frontend save (static blocks only, see --static) ├── editor.css # Editor-only styles ├── style.css # Shared styles (editor + frontend) └── view.js # Frontend-only script (optional) ``` Blocks are **dynamic by default**: `render.blade.php` renders them on each request, so their markup is not stored in `post_content`. Changing the markup updates every existing block instead of triggering the editor’s “This block contains unexpected or invalid content” error. `view.js` keeps its WordPress meaning — the frontend script — and the server template is `render.blade.php`. ### block.json [Section titled “block.json”](#blockjson) Standard WordPress [block metadata](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/). Asset fields use `file:./` references — Pollora resolves them through Vite: ```json { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "my-theme/hero-banner", "title": "Hero Banner", "category": "design", "icon": "cover-image", "textdomain": "my-theme", "editorScript": "file:./index.jsx", "editorStyle": "file:./editor.css", "style": "file:./style.css", "viewScript": "file:./view.js", "render": "file:./render.blade.php" } ``` ### index.jsx [Section titled “index.jsx”](#indexjsx) Entry point that registers the block with WordPress: ```jsx import { registerBlockType } from '@wordpress/blocks'; import Edit from './edit'; import metadata from './block.json'; import './editor.css'; import './style.css'; registerBlockType(metadata.name, { edit: Edit, save: window.pollora.blocks.save, // The inner blocks, if any: render.blade.php renders the rest }); ``` A static block (`--static`) imports `save` from `./save` and passes it instead. `window.pollora.blocks` is the framework’s editor runtime. Pollora loads it with every block that has a `render` template (the editor script handle `pollora-block-editor`); there is nothing to install. Its `save` stores the block’s inner blocks in the post and nothing else — for a block without inner blocks it is the same as `save: () => null`. ### edit.jsx [Section titled “edit.jsx”](#editjsx) The editor shows what the page will show. For a dynamic block, the generated `edit.jsx` asks the server to render `render.blade.php` for the block’s current attributes, and shows the result — with the block’s [inner blocks](#inner-blocks-in-a-blade-template) editable where the template writes ``: ```jsx import metadata from './block.json'; export default window.pollora.blocks.bladeEdit(metadata); ``` The preview is fetched from the core `block-renderer` REST route, again 200 ms after the last attribute change. A static block’s `edit.jsx` mirrors the markup `save.jsx` writes; a static block made with `--inner-blocks` uses Gutenberg’s `InnerBlocks` directly. ## Registration [Section titled “Registration”](#registration) Pollora registers the blocks of every active theme, plugin and module by itself: whatever holds a `resources/views/blocks` directory (or the former `resources/blocks`) has its blocks registered on WordPress `init`. There is nothing to write — no service provider, no `register_block_type()` call. A service provider of your own could not do this reliably. Over HTTP, WordPress is loaded, and `init` has fired, before theme and plugin providers boot; a REST request — which the editor and its block previews rely on — is answered before they boot at all. A block registered from a provider existed in WP-CLI only. A `BlocksServiceProvider` written by an earlier `pollora:make:block` is harmless — a block WordPress already holds is skipped — and can be deleted. Each block’s assets resolve through the asset container of the module that ships it: | Module type | Container name | | ----------- | --------------- | | Theme | `theme` | | Plugin | `plugin.{slug}` | | Module | `module.{slug}` | A Laravel module declares its own container in a provider that boots too late for `init`, so Pollora creates it when it is missing, building from `public/build/module/{slug}`. The `BlockRegistrar` automatically creates a `{container}.blocks` child container with an empty `basePath` to resolve block assets directly against the Vite manifest. Asset entry points are resolved relative to the Vite project root — `resources/views/blocks/hero-banner/index.jsx` — which is both the manifest key and the dev server path. The root is the closest parent directory holding a `vite.config.{js,ts,mjs}`, or else the directory containing `resources/`: keep the Vite config at the root of the theme, plugin or module. ## Vite Configuration [Section titled “Vite Configuration”](#vite-configuration) Block entry points must be registered in `vite.config.js`. The recommended pattern auto-discovers all block assets: ```js import { wordpressPlugin } from '@roots/vite-plugin'; import { globSync } from 'glob'; const blockEntries = globSync([ './resources/views/blocks/*/{index,view}.{js,jsx,ts,tsx}', './resources/views/blocks/*/{editor,style}.css', ]) .reduce((acc, file) => { acc[file.replace(/^\.\//, '').replace(/\.\w+$/, '')] = file; return acc; }, {}); const hasBlocks = Object.keys(blockEntries).length > 0; ``` Then in the Laravel Vite config: ```js input: ["./resources/assets/app.js", ...Object.values(blockEntries)], ``` And in `plugins`: ```js plugins: [ laravel(getThemeConfig()), ...(hasBlocks ? [wordpressPlugin()] : []), ], ``` The `@roots/vite-plugin` provides the `wordpressPlugin()` which generates `editor.deps.json` with WordPress script dependencies. ### Full reloads [Section titled “Full reloads”](#full-reloads) Blocks live under `resources/views`, so a `refresh` glob such as `resources/views/**` — including laravel-vite-plugin’s default `refreshPaths` — would reload the whole page on every block JSX change instead of hot-replacing it. Reload on Blade templates only: ```js refresh: [ ...refreshPaths.filter((refreshPath) => refreshPath !== 'resources/views/**'), 'resources/views/**/*.blade.php', ], ``` ## Tailwind CSS in Blocks [Section titled “Tailwind CSS in Blocks”](#tailwind-css-in-blocks) Pollora uses **Tailwind CSS v4** with automatic source detection. No `tailwind.config.js` is needed. Tailwind utilities work in blocks via two approaches: ### Utility Classes in JSX [Section titled “Utility Classes in JSX”](#utility-classes-in-jsx) Use Tailwind classes directly in your block JSX — they work on both frontend and editor: edit.jsx ```jsx export default function Edit() { const blockProps = useBlockProps(); return (

{__('Hello World', 'my-theme')}

{__('A description', 'my-theme')}

); } ``` ### `@apply` in Block CSS [Section titled “@apply in Block CSS”](#apply-in-block-css) Use `@apply` in `style.css` and `editor.css` for component-level styles: ```css /* style.css — loaded on frontend AND in editor */ @import "tailwindcss" source("."); .wp-block-my-theme-hero { @apply relative py-24 px-8 rounded-xl overflow-hidden; background: linear-gradient(135deg, theme(--color-indigo-950) 0%, theme(--color-violet-900) 100% ); } ``` ```css /* editor.css — loaded only in editor */ @reference "tailwindcss"; .wp-block-my-theme-hero { @apply border-2 border-dashed border-black/15 min-h-[300px]; } ``` ### Key Directives [Section titled “Key Directives”](#key-directives) | Directive | Use in | Purpose | | ----------------------------------- | ------------ | ------------------------------------------------------------------------------------------------ | | `@import "tailwindcss" source(".")` | `style.css` | Full Tailwind import, scoped to the block’s directory. Generates utility classes from JSX files. | | `@reference "tailwindcss"` | `editor.css` | Access to `@apply` and `theme()` without generating utilities. | | `theme(--color-*)` | Any CSS | Access Tailwind theme values as CSS functions. | ### Why `source(".")`? [Section titled “Why source(".")?”](#why-source) The `source(".")` parameter tells Tailwind to scan **only the block’s directory** for utility classes, not the entire project. This keeps the generated CSS small while ensuring all classes used in `edit.jsx`, `save.jsx`, and `index.jsx` are available in the editor iframe. Without `source(".")`, the block’s CSS would include utilities from the entire project — much larger than necessary. ## `pollora:make:block` Command [Section titled “pollora:make:block Command”](#polloramakeblock-command) ### Usage [Section titled “Usage”](#usage) ```bash php artisan pollora:make:block [options] ``` ### Arguments [Section titled “Arguments”](#arguments) | Argument | Description | | -------- | --------------------------------------------- | | `name` | Block slug in kebab-case (e.g. `hero-banner`) | ### Options [Section titled “Options”](#options) | Option | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------ | | `--theme[=NAME]` | Create in theme (default: active theme) | | `--plugin=NAME` | Create in plugin | | `--namespace=NS` | Block namespace (before the `/`) | | `--title=TITLE` | Block title in the inserter | | `--category=CAT` | Gutenberg category (default: `widgets`) | | `--icon=ICON` | Dashicon name (default: `block-default`) | | `--static` | Create a static block saved in `post_content` (`save.jsx`, no `render.blade.php`) | | `--dynamic` | Deprecated: blocks are dynamic by default | | `--inner-blocks` | Add inner blocks: `` in `render.blade.php` (`InnerBlocks` in `edit.jsx`/`save.jsx` with `--static`) | | `--no-view-script` | Skip frontend view script | | `--force` | Overwrite existing block | ### Examples [Section titled “Examples”](#examples) ```bash # Block rendered with Blade in the active theme php artisan pollora:make:block testimonial --theme --title="Testimonial" # Static block saved in post content php artisan pollora:make:block hero-banner --theme --static # Block with InnerBlocks in a plugin php artisan pollora:make:block accordion --plugin=my-plugin --inner-blocks # Custom namespace and category php artisan pollora:make:block pricing-table --theme --namespace=starter --category=design ``` ### First-Run Bootstrap [Section titled “First-Run Bootstrap”](#first-run-bootstrap) When creating the first block in a theme or plugin, the command automatically: 1. Patches `vite.config.js` with block entry discovery, `wordpressPlugin()` and Blade-only full reloads 2. Adds required npm dependencies (`@roots/vite-plugin`, `@wordpress/blocks`, etc.) A block needs a Vite build: the command refuses a theme or plugin that has no `package.json` or no `vite.config.js`, and writes nothing. A plugin made with `pollora:make:plugin --asset` has both. In a theme or plugin whose blocks are still in `resources/blocks`, it skips the bootstrap and updates the `vite.config.js` block entries to build both locations. ## Rendering with Blade [Section titled “Rendering with Blade”](#rendering-with-blade) `render.blade.php` receives the block’s `$attributes` (array), `$content` (inner blocks HTML), `$block` (`WP_Block`) and `$isPreview` (whether it renders for the editor’s preview): ```blade
'py-16']) !!}>

{{ $attributes['heading'] ?? '' }}

{{ $attributes['buttonText'] ?? __('Learn more', 'my-theme') }} {!! $content !!}
``` * `{{ }}` escapes; use `{!! !!}` only for `get_block_wrapper_attributes()` and `$content`, which WordPress already escaped. * For URLs, filter the protocol with `esc_url_raw()` and let `{{ }}` escape: `href="{{ esc_url_raw($url) }}"`. `esc_url()` already HTML-encodes, so inside `{{ }}` a `&` would come out as `&`. * Blade components work as in any view. The block’s `$attributes` array is restored after each `` tag, even though components use their own `$attributes` bag. * Tailwind classes used in the template are picked up as long as your CSS scans `resources/views`. A render file must stay inside the block directory: if `block.json` points outside it, or to a missing file, the block renders nothing and a warning is logged. A `render.php` file still works and is included as plain PHP, with the same variables. ## Inner blocks in a Blade template [Section titled “Inner blocks in a Blade template”](#inner-blocks-in-a-blade-template) Write `` where the block’s inner blocks go. In the editor, they are edited right there, inside the rendered template; on the page, they replace the tag: ```blade
'card']) !!}>

{{ $attributes['title'] ?? '' }}

``` On the page, the tag becomes the inner blocks inside a `div` carrying its `class`: ```html

Title

The inner blocks

``` The editor renders the same `div` around the editable blocks, so one stylesheet serves both. Without a `class`, the wrapper is `
`. * **Options**: the tag takes the options of Gutenberg’s inner blocks — `allowedBlocks`, `template`, `templateLock` (`"all"`, `"insert"`, `"contentOnly"`, `"false"`), `orientation`, `defaultBlock`, `directInsert`, `prioritizedInserterBlocks`, `templateInsertUpdatesSelection`. Arrays and objects are JSON, `"true"` and `"false"` booleans. Write JSON with `{{ json_encode(...) }}`: it escapes the quotes and the `>` the tag’s attributes may not hold raw. * **One per block**: Gutenberg keeps a single list of inner blocks per block. The first `` gets it; any further tag is dropped, in the editor and on the page. * **Where it can go**: anywhere in the template’s markup, at any depth. Both `` and `` work. * **Saved in the post**: the inner blocks are stored in `post_content` inside the block’s comment, the rest of the block is not. Changing the template updates every existing block and keeps its inner blocks. * **`$content`** holds the rendered inner blocks, as before; printing `{!! $content !!}` yourself still works, without the editor making them editable there. ### In the editor [Section titled “In the editor”](#in-the-editor) The preview is the template rendered by the server, turned into editor elements: `$isPreview` is true, `$content` is empty, and `` stays in the HTML for the editor to replace. A few things differ from the page: * ` ``` ### How It Works [Section titled “How It Works”](#how-it-works) The `Asset` facade leverages an internal `AssetFile` class that dynamically resolves the correct URL based on the specified asset path and container. The default container is now set to `root`, which handles native Laravel assets. If no container is specified, the framework falls back to the default container automatically. This means native Laravel assets can be referenced without specifying a container. ### Examples [Section titled “Examples”](#examples) ```php // Using the default root container for Laravel assets $appStyles = Asset::url('resources/css/app.css'); // Using a specific container for theme assets $themeImage = Asset::url('assets/images/logo.png')->from('theme'); // Switching to another container $sharedCss = Asset::url('assets/css/shared-styles.css')->from('shared'); ``` This updated approach provides greater flexibility and maintains clarity in your asset management workflow. It ensures that assets are always correctly referenced, regardless of their container or context. ## Important: Use of the Service Provider [Section titled “Important: Use of the Service Provider”](#important-use-of-the-service-provider) It is essential to declare assets in a **Service Provider** rather than a hook class. The service provider ensures the assets are properly enqueued via the WordPress `wp_enqueue_scripts` hook. By following these guidelines, you can ensure consistent and efficient asset management across your entire WordPress setup, leveraging the power of ViteJS and Pollora’s asset management system. # Menus > Customize WordPress menus in Pollora with rule-based classes and attributes by depth and position, made for Tailwind CSS and Alpine.js markup. Pollora let you enhance your WordPress menus with an elegant API for managing classes and attributes. This library provides granular control over each menu element, perfectly suited for modern frameworks like TailwindCSS and JavaScript libraries like AlpineJS. ## Features [Section titled “Features”](#features) * Rule-based configuration with depth and position targeting * Flexible attributes and classes management * Built-in translation support for menu labels ## Usage Guide [Section titled “Usage Guide”](#usage-guide) ### Configuration Structure [Section titled “Configuration Structure”](#configuration-structure) The library uses a rule-based approach to configure three types of elements: * Links (`link_config`) * List items (`item_config`) * Submenus (`submenu_config`) Each rule can include: * `depth`: Menu depth level (required) * `eq`: Specific item index (optional) * `class`: CSS classes to apply * `attrs`: Additional HTML attributes ### Basic Usage [Section titled “Basic Usage”](#basic-usage) ```php wp_nav_menu([ 'theme_location' => 'main_menu', 'link_config' => [ [ 'depth' => 0, 'class' => [ 'text-gray-700', 'hover:text-blue-500', 'sm:text-lg' ] ], [ 'depth' => 1, 'class' => 'text-gray-600' ] ] ]); ``` ### Advanced Examples [Section titled “Advanced Examples”](#advanced-examples) #### Targeting Specific Items [Section titled “Targeting Specific Items”](#targeting-specific-items) ```php wp_nav_menu([ 'theme_location' => 'main_menu', 'link_config' => [ [ 'depth' => 0, 'class' => 'text-xl', 'attrs' => ['data-tracking' => 'primary-link'] ], [ 'depth' => 0, 'eq' => 2, // Target the third item specifically 'class' => [ 'text-blue-500', 'hover:text-blue-700' ] ] ] ]); ``` #### Interactive Menu with AlpineJS [Section titled “Interactive Menu with AlpineJS”](#interactive-menu-with-alpinejs) ```php wp_nav_menu([ 'container' => 'nav', 'item_config' => [ [ 'depth' => 0, 'class' => 'relative', 'attrs' => ['x-data' => '{open: false}'] ] ], 'link_config' => [ [ 'depth' => 0, 'class' => [ 'flex items-center justify-between', 'hover:text-blue-500' ], 'attrs' => ['@click' => 'open = !open'] ] ], 'submenu_config' => [ [ 'depth' => 0, 'class' => [ 'pl-4 mt-2', 'transition-all' ], 'attrs' => [ 'x-show' => 'open', 'x-transition' => '' ] ] ] ]); ``` ### Menu Registration [Section titled “Menu Registration”](#menu-registration) By default, Pollora will automatically register menus defined in your theme configuration: config/theme.php ```php return [ 'menus' => [ 'primary_navigation' => 'Primary Navigation', 'footer_navigation' => 'Footer Links' ] ]; ``` ### Widget Support [Section titled “Widget Support”](#widget-support) Apply styles to menu widgets using the `widget_nav_menu_args` filter: ```php add_filter('widget_nav_menu_args', function($args, $nav_menu, $widget_args) { if ($widget_args['id'] === 'footer-menu') { $args['link_config'] = [ [ 'depth' => 0, 'class' => [ 'text-sm', 'text-gray-500', 'hover:text-gray-700' ] ] ]; } return $args; }, 10, 3); ``` ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Common Issues [Section titled “Common Issues”](#common-issues) * Verify that `depth` is set correctly for each rule * Check that `eq` values match your menu structure (0-based indexing) * Validate attribute array syntax * Ensure your theme configuration is properly loaded ### Debug Tips [Section titled “Debug Tips”](#debug-tips) 1. Check the rendered HTML to see if classes are being applied 2. Use browser dev tools to inspect the menu structure 3. Enable WordPress debug mode to catch any potential errors 4. Verify menu registration in WordPress admin panel # Theme Structure > Create a Pollora theme: folder structure, theme.json, Blade templates and the template hierarchy, Vite and Tailwind CSS, localization and the login screen. ## Introduction [Section titled “Introduction”](#introduction) The Pollora framework offers a robust and flexible system for creating and managing WordPress themes. This guide will help you understand how to create, customize, and use themes with Pollora. ## Creating a New Theme [Section titled “Creating a New Theme”](#creating-a-new-theme) ### Generating a New Theme [Section titled “Generating a New Theme”](#generating-a-new-theme) To generate a new theme, run the following command: ```bash php artisan pollora:make:theme ``` You’ll be prompted to answer several questions to configure your theme, starting with the template to start from: | Template | Repository | What it is | | ---------- | ----------------------- | ------------------------------------------------------------------------------------- | | Default | `pollora/theme-default` | A Blade starter: Vite, Tailwind CSS | | E-commerce | `pollora/theme-apiary` | A WooCommerce storefront, Blade and Alpine.js | | Magazine | `pollora/theme-buzz` | A Full Site Editing block theme — see [Block Themes](#block-themes-full-site-editing) | | Custom | any `owner/repo` | Your own template, from GitHub | `--repository=pollora/theme-buzz` skips the prompt. The command downloads the template’s latest tag and fills in its placeholders (name, namespace, pattern slugs, `style.css` header). Alternatively, you can pass the configuration as options: ```bash php artisan pollora:make:theme {theme-name} \ --theme-author="Author Name" \ --theme-author-uri="https://author.com" \ --theme-uri="https://theme.com" \ --theme-description="Theme description" \ --theme-version="1.0.0" ``` ### Command Options [Section titled “Command Options”](#command-options) * `--theme-author` : Theme author name * `--theme-author-uri` : Theme author URI * `--theme-uri` : Theme URI * `--theme-description` : Theme description * `--theme-version` : Theme version * `--repository` : GitHub repository to download (owner/repo format) * `--repo-version` : Specific version/tag to download * `--force` : Force create theme with same name * `--activate` : Activate the generated theme without asking * `--no-activate` : Leave the active theme as it is, without asking This command creates a new theme with the necessary folder structure and base files. It activates the new theme only where the site needs one: a site with no usable theme — a first install — gets it without a question; a site that already has one is asked, **No** by default, so `--no-interaction` never replaces a working theme. `--activate` and `--no-activate` decide without asking. Activation goes through WordPress’s `switch_theme()`, so `after_switch_theme` runs. ### Theme Structure [Section titled “Theme Structure”](#theme-structure) A typical Pollora theme has the following structure: ```plaintext theme-name/ ├─ config/ │ ├─ gutenberg.php │ ├─ images.php │ ├─ login.php │ ├─ menus.php │ ├─ providers.php │ ├─ sidebars.php │ ├─ supports.php │ ├─ templates.php ├─ assets/ │ └─ css/ │ └─ app.css │ └─ fonts/ │ └─ images/ │ └─ js/ │ `└─ `bootstrap.js │ └─ app.js ├─ views/ │ ├─ example.blade.php │ ├─ layouts/ │ ├─ parts/ │ ├─ patterns/ │ ├─ home.blade.php │ ├─ page.blade.php │ └─ post.blade.php ├─ favicon.png ├─ index.php ├─ package.json ├─ style.css ├─ theme.json └─ vite.config.js ``` ### Theme Registration (functions.php) [Section titled “Theme Registration (functions.php)”](#theme-registration-functionsphp) The theme’s `functions.php` file registers the theme with the Pollora framework using the `pollora_register()` helper: ```php templateHierarchy = $templateHierarchy; } public function show() { $hierarchy = $this->templateHierarchy->hierarchy(); return view('page', ['templateHierarchy' => $hierarchy]); } } ``` ```blade {{-- In a Blade view --}}

Template Hierarchy

    @foreach($templateHierarchy as $template)
  • {{ $template }}
  • @endforeach
``` ### Extending the Template Hierarchy [Section titled “Extending the Template Hierarchy”](#extending-the-template-hierarchy) Plugins can extend the template hierarchy for specific content types. You can inject the `TemplateHierarchy` class into your service providers or use the container to resolve it: ```php // In a plugin or theme service provider use Pollora\Theme\TemplateHierarchy; class ThemeServiceProvider extends ServiceProvider { public function boot(TemplateHierarchy $templateHierarchy) { // Register a custom template handler for product pages on sale $templateHierarchy->registerTemplateHandler('product_on_sale', function($queriedObject) { if (!$queriedObject || !function_exists('wc_get_product')) { return []; } $product = wc_get_product($queriedObject->ID); if (!$product || !$product->is_on_sale()) { return []; } return [ "product-on-sale-{$product->get_slug()}.blade.php", 'product-on-sale.blade.php', ]; }); // Add the corresponding condition add_filter('pollora/template_hierarchy/conditions', function($conditions) { $conditions['is_product_on_sale'] = 'product_on_sale'; return $conditions; }); } } ``` ## Vite Configuration [Section titled “Vite Configuration”](#vite-configuration-1) The `vite.config.js` file is automatically generated and configured for your theme. It includes: * Automatic theme name detection * Configuration for development and production * Integration with the Laravel Vite plugin ```javascript import { defineConfig } from "vite"; import laravel from "laravel-vite-plugin"; import path from 'path'; const isDevelopment = !!process.env.DDEV_PRIMARY_URL; const port = 5173; const publicDirectory = path.resolve(__dirname, "../../public"); const themeName = path.basename(__dirname); // ... (detailed configuration) export default defineConfig({ plugins: [ laravel(getThemeConfig()), // ... (other plugins) ], ...getDevServerConfig() }); ``` ## Tailwind CSS Integration [Section titled “Tailwind CSS Integration”](#tailwind-css-integration) Pollora themes use **Tailwind CSS v4** with the `@tailwindcss/vite` plugin. No `tailwind.config.js` or `postcss.config.mjs` is needed — Tailwind v4 auto-detects source files from the Vite module graph. ### Setup [Section titled “Setup”](#setup) The `package.json` includes the necessary dependencies: ```json { "devDependencies": { "@tailwindcss/vite": "^4.2.3", "tailwindcss": "^4.2.3", "vite": "^8.0.9" } } ``` The Vite plugin handles everything — add it in `vite.config.js`: ```js import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ tailwindcss(), // ... ], }); ``` ### CSS Entry Point [Section titled “CSS Entry Point”](#css-entry-point) Your main CSS file uses a single import: resources/assets/app.css ```css @import "tailwindcss"; ``` Tailwind v4 automatically scans all project files (HTML, PHP, JSX, Blade templates) for utility classes and generates only the CSS needed. ### Tailwind in Gutenberg Blocks [Section titled “Tailwind in Gutenberg Blocks”](#tailwind-in-gutenberg-blocks) Block CSS files (`style.css`, `editor.css`) support Tailwind via two directives: * **`@import "tailwindcss" source(".")`** in `style.css` — imports Tailwind scoped to the block’s directory. This generates utility classes found in the block’s JSX files, ensuring they work both on the frontend and in the block editor. * **`@reference "tailwindcss"`** in `editor.css` — gives access to `@apply` without generating utilities (editor-only styles typically use `@apply`, not utility classes in JSX). resources/views/blocks/hero/style.css ```css @import "tailwindcss" source("."); .wp-block-my-theme-hero { @apply relative py-24 px-8 overflow-hidden; } ``` > See [Gutenberg Blocks](/blocks/gutenberg-blocks/) for detailed examples of Tailwind usage in Gutenberg blocks. ## Theme Management [Section titled “Theme Management”](#theme-management) Pollora’s `ThemeManager` offers several useful methods for managing themes: * `Theme::load($themeName)`: Loads a specific theme * `Theme::getAvailableThemes()`: Retrieves the list of available themes * `Theme::active()`: Returns the name of the active theme * `Theme::parent()`: Returns the name of the parent theme (if it’s a child theme) * `Theme::path($path)`: Generates the full path to a file in the active theme * `Theme::asset($path, $assetType = '')`: Retrieves the URL for a theme asset ### Using Theme Assets [Section titled “Using Theme Assets”](#using-theme-assets) The framework provides an intuitive way to manage and reference theme assets through the `Asset` facade. The `Asset` facade ensures you can easily retrieve URLs for assets in the active theme’s container. #### Accessing Theme Assets [Section titled “Accessing Theme Assets”](#accessing-theme-assets) You can reference theme assets with the following examples: ```php use Pollora\Support\Facades\Asset; // Get the URL for an image in the theme $logoUrl = (string)Asset::url('assets/images/logo.png'); // the string cast is necessary to return the url // Get the URL for a CSS file in the theme $styleUrl = (string)Asset::url('assets/css/app.css'); // Get the URL for a JavaScript file in the theme $scriptUrl = (string)Asset::url('assets/js/app.js'); ``` #### WordPress’s own theme URL functions [Section titled “WordPress’s own theme URL functions”](#wordpresss-own-theme-url-functions) `get_theme_file_uri()` works too, and resolves through the same build: ```php get_theme_file_uri('resources/assets/app.js'); // https://example.test/build/theme/my-theme/assets/app-DcI6_eae.js get_theme_file_uri('fonts/Inter-Regular.woff2'); // relative to the container root // https://example.test/build/theme/my-theme/assets/Inter-Regular-B0QUfDW0.woff2 ``` Both spellings are accepted: the path from the theme’s root, and the path relative to the asset container’s root (`resources/assets/` by default). A file the build does not know about is handed back with WordPress’s own answer, unchanged. That answer is **not fetchable**: a theme’s own directory is not web-served on a Pollora project — only the build output under `/build/theme/{slug}` is. If you need a URL for a file, make it part of the Vite build. The same applies to `get_stylesheet_directory_uri()` and `get_template_directory_uri()`: they answer a URL, but not one that serves your theme’s files. Use `Asset::url()` or `get_theme_file_uri()`. #### Explicitly Specifying the Theme Container [Section titled “Explicitly Specifying the Theme Container”](#explicitly-specifying-the-theme-container) Although the framework defaults to the active theme’s container, you can explicitly specify it using the `from('theme')` method for clarity: ```php use Pollora\Support\Facades\Asset; // Explicitly specify the "theme" container $logoUrl = (string)Asset::url('assets/images/logo.png')->from('theme'); // the string cast is necessary to return the url $styleUrl = (string)Asset::url('assets/css/app.css')->from('theme'); $scriptUrl = (string)Asset::url('assets/js/app.js')->from('theme'); ``` #### Blade Integration for Theme Assets [Section titled “Blade Integration for Theme Assets”](#blade-integration-for-theme-assets) You can reference theme assets directly in your Blade templates, with or without specifying the container explicitly: ```blade ``` Or, explicitly specify the theme container: ```blade