Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Skills** `pollora-typed-meta`, `pollora-roles` and `pollora-block-bindings`: `#[Meta]` and `Meta::of()`, `#[Role]` / `#[ModifyRole]` / `#[CapabilitySet]` and Laravel's Gate, `#[BlockBinding]` and the `pollora/*` sources, with their commands and doctor checks (framework v13.34.4 to v13.35)
- **Core guidelines**: the three features in the attribute snippet and the conventions, the `Meta` facade, and the commands `pollora:make:role`, `pollora:make:binding`, `pollora:meta:list|audit`, `pollora:roles:list|show|prune|import|dump`, `pollora:binding:list`
- **13.x guidelines**: typed meta, roles and Block Bindings; the request lifecycle (`template_redirect` after every provider has booted, since v13.35.1)

### Changed

- **Upgrade prompt** and 13.x guidelines: `^13.35` (Laravel 13.35)
- **REST skill**: the `Can` permission (a capability, a `#[CapabilitySet]` case, a meta capability on a request parameter)

### Fixed

- **REST skill**: the built-in permissions are imported from `Pollora\WpRest\Permissions` and the contract from `Pollora\Attributes\WpRestRoute\Permission`; the namespaces it gave did not exist

## [1.3.4] - 2026-10-07

### Changed
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ php artisan boost:update
| Feature | Description |
|---------|-------------|
| **AI Guidelines** | Pollora architecture, PHP attributes, WordPress routing, Blade theming — injected into your agent's context via Boost |
| **9 Agent Skills** | On-demand knowledge for post types, taxonomies, theming, hooks, blocks, REST API and AJAX, scheduling, modules, and abilities |
| **12 Agent Skills** | On-demand knowledge for post types, taxonomies, typed meta, roles, theming, hooks, blocks and Block Bindings, REST API and AJAX, scheduling, modules, and abilities |
| **10 MCP Tools** | Live introspection of your WordPress & Pollora environment directly from your AI agent |
| **Upgrade Prompts** | Step-by-step MCP prompts for upgrading between Pollora versions (12 → 13, 13.x → 13.32) |
| **Upgrade Prompts** | Step-by-step MCP prompts for upgrading between Pollora versions (12 → 13, 13.x → 13.32 and later) |

## MCP Server

Expand Down Expand Up @@ -115,6 +115,9 @@ Skills are activated on-demand when working on specific tasks:
| `pollora-rest-api` | REST API endpoints with `#[WpRestRoute]`, AJAX handlers with `#[Ajax]` |
| `pollora-scheduling` | Scheduled tasks with `#[Schedule]` |
| `pollora-abilities` | WordPress Abilities API with `#[Ability]` and the `Ability` facade |
| `pollora-typed-meta` | Post, term, user and comment meta as typed properties with `#[Meta]`, `Meta::of()`, `pollora:meta:audit` |
| `pollora-roles` | Capabilities through Laravel's Gate, roles declared with `#[Role]`, `#[ModifyRole]`, `#[CapabilitySet]`, `pollora:roles:*` |
| `pollora-block-bindings` | `#[BlockBinding]` sources, `pollora/post-meta` and the other sources for core blocks, bindable Blade blocks |
| `pollora-modules` | Laravel Modules with auto-discovery |

## Upgrade Assistance
Expand Down
31 changes: 29 additions & 2 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,27 @@ public function loadMore(): void {}
#[Ability(name: 'acme/create-post', description: 'Creates a post.', category: 'acme-content')]
final class CreatePost implements AbilityHandler {}

// Typed meta (v13.34.4+, experimental) — a typed property, on the post type or taxonomy class
#[PostType('event')]
class Event
{
#[Meta(showInRest: true)]
public int $capacity = 0; // key: capacity, read with Meta::of(Event::class, $id)->capacity
}

// Roles declared in code (v13.34.4+, experimental) — injected on every request, never stored
#[Role('event_manager', label: 'Event manager', inherits: 'author')]
#[GrantsPostType(Event::class, Access::Editor)]
final class EventManager {}

// Block Bindings sources (v13.34.6+, experimental) — fields a core block's attribute binds to
#[BlockBinding('acme/event', postTypes: 'event')]
final class EventBinding
{
#[BindingField(label: 'Remaining seats')]
public function remainingSeats(BindingContext $context): string {}
}

// Keep a class out of discovery entirely (or all but some: except: [...])
#[SkipDiscovery]
class InternalHelper {}
Expand Down Expand Up @@ -102,10 +123,11 @@ class InternalHelper {}
- **Never call WordPress registration functions directly** — use attributes and discovery
- **Use Blade directives** from Sage Directives: `@posts`, `@title`, `@content`, `@permalink`, `@published`
- **WordPress objects** (`WP_Post`, `WP_Query`, `WP_User`) are auto-injected via type hints in controller methods
- **Facades**: `Action`, `Filter`, `Ajax`, `Asset`, `Theme`, `PostType`, `Taxonomy`, `Option`, `Ability`, `PostQuery`, `MetaQuery`, `TaxQuery`, `Mail`, `Constant`. There is no `Loop` or `Query` facade: use Sage Directives in Blade, WordPress functions in PHP
- **Facades**: `Action`, `Filter`, `Ajax`, `Asset`, `Theme`, `PostType`, `Taxonomy`, `Option`, `Meta`, `Ability`, `PostQuery`, `MetaQuery`, `TaxQuery`, `Mail`, `Constant`. There is no `Loop` or `Query` facade: use Sage Directives in Blade, WordPress functions in PHP
- **Translations**: `__('Text', 'my-domain')` goes to WordPress's catalogues; `__('key', ['name' => $x])` goes to Laravel; `__('Text')` tries Laravel, then WordPress's `default` domain
- **Blocks**: live in `resources/views/blocks/{slug}`, render with `render.blade.php` by default, and are registered by Pollora — never write a `BlocksServiceProvider`; `<InnerBlocks />` in `render.blade.php` marks where inner blocks go, edited in place in the editor (see the `pollora-blocks` skill)
- **Which template answered?** With `WP_DEBUG` on, every page carries `<!-- pollora:template="single" path="themes/x/resources/views/single.blade.php" -->` (hierarchy responses only, not `Route::wp()` or Laravel routes)
- **Meta, roles, bindings**: declare a meta with `#[Meta]` on a typed property rather than calling `register_meta()` and casting `get_post_meta()` by hand (`pollora-typed-meta` skill); declare roles with `#[Role]` / `#[ModifyRole]` rather than `add_role()`, and check a capability (`can:`, `@can`) rather than a role (`pollora-roles` skill); bind core blocks to a `#[BlockBinding]` source or `pollora/post-meta` before writing a custom block (`pollora-block-bindings` skill)
- **CSRF**: WordPress endpoints are excluded from Laravel CSRF — WordPress uses its own nonce system
- **Modules**: Use `nwidart/laravel-modules` for large projects — discovery works inside modules automatically

Expand All @@ -119,9 +141,14 @@ class InternalHelper {}
- `pollora:make:post-type` / `pollora:make:taxonomy` — Generate post type and taxonomy classes
- `pollora:make:action` / `pollora:make:filter` / `pollora:make:hook` — Generate hook classes
- `pollora:make:model` / `pollora:make:wp-cli` — Generate an Eloquent model or a WP-CLI command class
- `pollora:make:role` / `pollora:make:binding` — Generate a `#[Role]` class or a `#[BlockBinding]` source
- `pollora:meta:list` / `pollora:meta:audit` — List typed meta; read every stored value and name those that cannot be read as their type (exits 1, for CI)
- `pollora:roles:list` / `pollora:roles:show {role}` — Roles with their origin; the effective capabilities of one role and where each comes from
- `pollora:roles:prune` / `pollora:roles:import {role}` / `pollora:roles:dump` — Clean up roles removed from the code, turn a stored role into a `#[Role]` class, write the code's roles to the database; dry run unless `--force`
- `pollora:binding:list` — Binding sources, their fields, and the blocks WordPress lets bind
- `discovery:run` / `discovery:clear` — Manage component discovery cache
- `pollora:status` — Show framework status
- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each; the same checks are in Tools › Site Health. **Run it first when something fails without an error**
- `pollora:doctor` — Check the project for silent failures (core patch, patches lock, `.env`, discovery cache, theme build, patterns…) and print the fix for each, including bindings that can never show a value, typed meta that cannot register and users left with a removed role; the same checks are in Tools › Site Health. **Run it first when something fails without an error**

Commands use the colon convention since v13.32; the former dashed names (`pollora:make-theme`…) still work as aliases.

Expand Down
10 changes: 9 additions & 1 deletion resources/boost/guidelines/framework.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@

### Laravel 13 Compatibility

- Requires `illuminate/*` `^13.34`; v13.34.0 is the first stable release on it
- Requires `illuminate/*` `^13.35` since v13.35.0 (`^13.34` before); v13.34.0 is the first stable release
- Supports Pest 3.x for testing
- PHPStan level 5 with WordPress and Laravel extensions
- Rector with Laravel-specific rules
Expand All @@ -113,6 +113,14 @@
- Gutenberg blocks with Vite + JSX/TSX + Tailwind CSS v4, rendered with Blade
- Abilities API through `pollora/abilities` (WordPress 6.9+)

### Typed Meta, Roles and Block Bindings

Experimental, from v13.34.4 (meta, roles) and v13.34.6 (Block Bindings): `#[Meta]` typed properties read through `Meta::of()` and the `Pollora\Models` models, roles declared with `#[Role]` / `#[ModifyRole]` / `#[CapabilitySet]` and injected on every request, and `#[BlockBinding]` sources plus `pollora/post-meta`, `term-meta`, `author-meta` and `option` for core blocks. See the `pollora-typed-meta`, `pollora-roles` and `pollora-block-bindings` skills; `pollora:doctor` checks all three.

### Request Lifecycle

WordPress is loaded from a service provider's `boot()`; `wp()` runs there. Since v13.35.1, `template_redirect` (then `pollora_loaded`) fires once every provider has booted, still before routing, so a plugin or theme rendering a view from `template_redirect` gets the theme's view composers and shared data. Code that relied on `template_redirect` having fired during the providers' boot must move to a later hook.

### Extracted Packages

Some modules now live in their own packages, installed with the framework: `pollora/hook` (hook domain and adapters), `pollora/option`, `pollora/ajax`, `pollora/abilities`.
Expand Down
101 changes: 101 additions & 0 deletions resources/boost/skills/pollora-block-bindings/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
name: pollora-block-bindings
description: Fill core block attributes with server-side values using Pollora's #[BlockBinding] sources, the pollora/post-meta, term-meta, author-meta and option sources for typed meta, and bindable Blade blocks.
---

# Pollora Block Bindings

## When to use this skill
Use this skill when a page built from core blocks (paragraph, heading, button, image) must show a value computed on the server — a meta, a count, a link — or when a Blade block's attribute should be bindable. Requires Pollora v13.34.6+ and WordPress 6.9+ (Pollora installs WordPress 7); **experimental**.

Prefer a binding on a core block to writing a custom block that only displays a value.

## Declaring a source

```php
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); // typed meta of the block's post

return (string) max(0, $event->capacity - $bookings->countFor($context->postId));
}

#[BindingField(label: 'Booking link', type: 'url')]
public function bookingUrl(BindingContext $context): string
{
return route('events.book', ['event' => $context->postId]);
}
}
```

`php artisan pollora:make:binding EventBinding` generates one (`--theme`, `--plugin`, `--module`). Discovered everywhere; the container injects dependencies into the constructor and each field.

| Parameter | Default | Effect |
|---|---|---|
| `#[BlockBinding(name)]` | required | `namespace/name`, lowercase |
| `label` | class name | Shown in the editor |
| `usesContext` | `['postId', 'postType']` | Block context read |
| `postTypes` | all | Elsewhere the block keeps its content |
| `#[BindingField(name)]` | method in snake_case | The `field` argument |
| `type` | `text` | `url` or `image` values are sanitized as URLs |

A class without fields defines `__invoke(BindingContext $context)`.

`BindingContext`: `postId`, `postType` (right inside a query loop), `termId`, `taxonomy`, `arg('name', $default)`, `attribute`, `block`, `post()` (a `Pollora\Models\Post`), `meta(Class::class)`.

A field declares its return type: `string`, `int`, `float`, `bool`, `Stringable` or `null` (an array or no return type is refused at discovery). `null` keeps the block's saved text. Pollora escapes for the place the value lands in (text escaped, URLs sanitized, `HtmlString` through `wp_kses_post()`): do not escape in the field. A field that throws is logged and reads as `null` (thrown in debug mode); in debug mode a field slower than 50 ms is logged.

## Binding in markup

```html
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"acme/event","args":{"field":"remaining_seats"}}}}} -->
<p>Seats available</p>
<!-- /wp:paragraph -->
```

In the editor, **Attributes** in the block settings offers the fields; a bound block previews its value (read-only) through `POST /wp-json/pollora/v1/block-bindings/resolve`. No JavaScript to write.

## Typed meta in core blocks

| Source | Reads | Arguments |
|---|---|---|
| `pollora/post-meta` | a typed meta of the block's post | `key`, `format`, `decimals`, `true`, `false`, `size`, `fallback` |
| `pollora/term-meta` | a typed meta of the block's term, or the queried term | same |
| `pollora/author-meta` | a typed meta of the post's author, only `#[Meta(public: true)]` | same |
| `pollora/option` | a site option listed in `config/block-bindings.php` → `options` (none by default) | `name`, `fallback` |

Values are formatted by type: dates in the site's format and language (`format`), numbers with its separators (`decimals`), booleans as Yes/No, enums by `label()`, arrays as a list; `format: raw` gives the stored value. A `#[Meta(media: true)]` attachment ID bound to an image gives its URL (`size`), `alt`, `title` or `caption` according to the attribute. Post title, link and date come from WordPress's `core/post-data`.

Post and term meta are only shown with `showInRest: true` and a key not starting with `_`. To let editors **edit** a meta from a block, bind `core/post-meta` instead: Pollora's sources are read-only.

## Bindable Blade blocks

```json
{
"name": "acme/event-card",
"attributes": { "title": { "type": "string" }, "ctaUrl": { "type": "string" } },
"pollora": { "bindings": ["title", "ctaUrl"] },
"render": "file:./render.blade.php"
}
```

The view reads `$attributes['title']` as usual and escapes it (`{{ }}`). Only a block with a `render` can be bound.

## Checking

- `php artisan pollora:doctor` — every binding in the theme's, plugins' and modules' templates, parts and patterns that can never show a value (unknown source or field, undeclared or non-public meta, unlisted option, attribute not bindable), with file, block and reason. Bindings saved in posts are not read
- `php artisan pollora:binding:list` (`--json`) — sources, fields, the meta and options each may show, bindable blocks

## Important Notes

- Never read the logged-in visitor in a source: the value would leak through the page cache
- A field runs once per post, attribute and arguments per request, but in a query loop of 20 posts with 4 bound blocks that is 80 calls: no uncached query in a field
- Run `php artisan discovery:clear` after adding a source in development
2 changes: 2 additions & 0 deletions resources/boost/skills/pollora-post-types/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,6 @@ public function show(\WP_Post $post): \Illuminate\View\View
- Run `php artisan discovery:clear` after creating a new post type class during development
- Labels are auto-generated from the class name if not specified
- The slug is derived from the `#[PostType('slug')]` attribute parameter
- Custom fields: declare them as `#[Meta]` typed properties on the same class (see the `pollora-typed-meta` skill), not with `register_meta()`
- `#[CapabilityType]` + `#[MapMetaCap]` give the post type its own capabilities, which no role has except the super roles: grant them with `#[GrantsPostType]` (see the `pollora-roles` skill)
- Generate a post type with `php artisan pollora:make:post-type`
17 changes: 14 additions & 3 deletions resources/boost/skills/pollora-rest-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ Endpoint: `GET /wp-json/my-theme/v1/documents/18`
Use built-in permission classes or create custom ones:

```php
use Pollora\Attributes\WpRestRoute\Permissions\IsAdmin;
use Pollora\Attributes\WpRestRoute\Permissions\IsLoggedIn;
use Pollora\WpRest\Permissions\Can;
use Pollora\WpRest\Permissions\IsAdmin;
use Pollora\WpRest\Permissions\IsLoggedIn;

// Built-in permissions
#[WpRestRoute(
Expand All @@ -102,8 +103,16 @@ use Pollora\Attributes\WpRestRoute\Permissions\IsLoggedIn;
)]
class SettingsAPI {}

// A capability (v13.34.5+): a string or a #[CapabilitySet] enum case
#[WpRestRoute(namespace: 'my-theme/v1', route: 'reports', permissionCallback: new Can('export_attendees'))]
class ReportsAPI {}

// A meta capability on the object in the request: edit_post with the `id` parameter
#[Method('PUT', permissionCallback: new Can('edit_post', parameter: 'id'))]
public function update(int $id): array {}

// Custom permission
use Pollora\WpRest\Domain\Contracts\Permission;
use Pollora\Attributes\WpRestRoute\Permission;

class CanManageBooks implements Permission
{
Expand All @@ -123,6 +132,8 @@ class CanManageBooks implements Permission
class BooksAPI {}
```

A `Can` refusal answers 401 to a guest and 403 to a logged-in user. Prefer `Can` to a custom class that only calls `current_user_can()` (see the `pollora-roles` skill).

## Theme API Routes (Lightweight Mode)

For performance-critical API endpoints, themes can define routes that load WordPress **without plugins** (~100ms vs ~1.3s):
Expand Down
Loading
Loading