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 `