diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d395d5..36660d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 982b0e2..8cb1aad 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
index 068594c..b4f782a 100644
--- a/resources/boost/guidelines/core.blade.php
+++ b/resources/boost/guidelines/core.blade.php
@@ -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 {}
@@ -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`; `
Seats available
+ +``` + +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 diff --git a/resources/boost/skills/pollora-post-types/SKILL.md b/resources/boost/skills/pollora-post-types/SKILL.md index d278c44..fbd402d 100644 --- a/resources/boost/skills/pollora-post-types/SKILL.md +++ b/resources/boost/skills/pollora-post-types/SKILL.md @@ -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` \ No newline at end of file diff --git a/resources/boost/skills/pollora-rest-api/SKILL.md b/resources/boost/skills/pollora-rest-api/SKILL.md index 641fe7d..f2775f3 100644 --- a/resources/boost/skills/pollora-rest-api/SKILL.md +++ b/resources/boost/skills/pollora-rest-api/SKILL.md @@ -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( @@ -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 { @@ -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): diff --git a/resources/boost/skills/pollora-roles/SKILL.md b/resources/boost/skills/pollora-roles/SKILL.md new file mode 100644 index 0000000..31932b3 --- /dev/null +++ b/resources/boost/skills/pollora-roles/SKILL.md @@ -0,0 +1,121 @@ +--- +name: pollora-roles +description: Check WordPress capabilities through Laravel authorization, and declare roles in code with Pollora's #[Role], #[ModifyRole] and #[CapabilitySet] attributes instead of storing them in the database. +--- + +# Pollora Roles & Capabilities + +## When to use this skill +Use this skill when restricting who can do something (routes, Blade, REST, PHP), giving a post type its own capabilities, creating or changing a role, or cleaning up roles. Checking capabilities is stable; declaring roles and checking role classes is **experimental** (v13.34.4+; `hasRole()`, `role:`, `@role` with classes since v13.34.5). + +Do not call `add_role()` / `add_cap()`: they write to the database once and drift between environments. Declare roles in code. + +## Checking capabilities + +Laravel's authorization answers with WordPress capabilities: + +```php +$user = auth()->user(); // Pollora\Models\User +$user->can('edit_posts'); +$user->can('edit_post', $post); // meta capability, map_meta_cap() +Gate::authorize('manage_options'); // 403 otherwise + +Route::get('/reports', ReportController::class)->middleware('can:manage_options'); +``` + +```blade +@can('edit_post', $post) … @endcan +@canany(['edit_posts', 'moderate_comments']) … @endcanany +@role('editor', EventManager::class) … @endrole +``` + +In a `#[WpRestRoute]`, use `permissionCallback: new Can('edit_posts')` (or `new Can('edit_post', parameter: 'id')`). + +**Check a capability, not a role**: `can:export_attendees` stays right when a second role gets the capability; `role:event_manager` does not. + +## A post type with its own capabilities + +```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 `edit_events`, `publish_events`… that no role has: Pollora gives them to the super roles (`administrator` by default, `config/roles.php` → `super_roles`); grant them to other roles with `#[GrantsPostType]`. + +## Declaring a role + +`php artisan pollora:make:role EventManager` generates one in `app/Cms/Roles`. + +```php +use Pollora\Attributes\Role; +use Pollora\Attributes\Role\{Grants, GrantsPostType, Without}; +use Pollora\Role\Domain\Enums\Access; + +#[Role('event_manager', label: 'Event manager', inherits: 'author', textDomain: 'my-theme')] +#[GrantsPostType(Event::class, Access::Editor)] // Contributor < Author < Editor +#[Grants(EventCap::ExportAttendees, 'moderate_comments')] +#[Without('publish_posts')] +final class EventManager {} +``` + +| Attribute | Effect | +|---|---| +| `#[Role(slug, label, inherits, allowSensitive, textDomain)]` | Declares the role; `inherits` is a slug or another `#[Role]` class, followed live | +| `#[Grants(...)]` / `#[Without(...)]` | Adds / removes capabilities (strings or enum cases); repeatable | +| `#[GrantsPostType(class or slug, Access)]` | Capabilities of a post type with `#[CapabilityType]` | +| `#[GrantsTaxonomy(class or slug)]` | Term capabilities of a taxonomy with its own `#[Capabilities]` | +| `#[ModifyRole('editor')]` | Adjusts a role the project does not own (core, WooCommerce, a plugin) with the same `Grants`/`Without` attributes | + +Project capabilities go in a backed enum: + +```php +#[CapabilitySet(label: 'Events')] +enum EventCap: string +{ + case ExportAttendees = 'export_attendees'; +} +// Gate::allows(EventCap::ExportAttendees); super roles receive every case +``` + +### How it works + +Declared roles are injected into WordPress's role registry **in memory on every request** (`wp_roles_init`), never written to `{prefix}user_roles`. The code is the source of truth: deploy and the role follows; remove the class and the role is gone (Pollora undoes what a plugin's `add_cap()` wrote back). Role editor plugins cannot change a declared role. + +### Safety rules (enforced at discovery, the declaration is refused and logged) + +- Sensitive capabilities (`manage_options`, `edit_users`, `promote_users`, `unfiltered_html`, `install_plugins`, `edit_themes`, `update_core`…) need `allowSensitive: true` +- Core roles cannot be redeclared with `#[Role]`: use `#[ModifyRole]` +- No inheritance from a super role, no loop, no slug declared twice, no capability both granted and removed +- `#[Without]` removes a capability; it never stores `false` + +## Checking roles in code + +```php +$user->roles(); // ['author', 'event_manager'] +$user->hasRole(EventManager::class); // or slugs: hasRole('editor', 'administrator') +$user->assignRole(EventManager::class); // through WP_User; refuses an unknown role +$user->removeRole('event_manager'); + +Route::middleware('role:event_manager,editor'); +Route::get('/scan', ScanController::class)->middleware(EnsureUserHasRole::using(EventManager::class)); +``` + +`assignRole()` / `removeRole()` do not check the caller's rights: check `promote_users` where a user can trigger them. Your own user model gets them with the `Pollora\Models\Concerns\HasRoles` trait (needs `toWpUser()`). + +## Inspecting and migrating + +- `pollora:roles:list` / `pollora:roles:show event_manager` (`--json`) — roles with their origin and users; effective capabilities and where each comes from +- `pollora:doctor` — users still carrying a removed role, a `default_role` that no longer exists, capabilities given to users one by one, declarations that could not apply +- `pollora:roles:prune --reassign=subscriber` — takes removed roles off users; `pollora:roles:import venue_staff --inherits=author` — turns a stored role into a `#[Role]` class (review it); `pollora:roles:dump` — writes the code's roles to the option for tools that read the database. All three are a dry run unless `--force` + +## Important Notes + +- A tool reading `user_roles` directly, without loading the site, does not see declared roles (use `pollora:roles:dump`) +- Roles from plugins and themes are injected after WordPress built its roles, and the current user's capabilities are recomputed +- Run `php artisan discovery:clear` after adding a role class in development diff --git a/resources/boost/skills/pollora-typed-meta/SKILL.md b/resources/boost/skills/pollora-typed-meta/SKILL.md new file mode 100644 index 0000000..df3cd95 --- /dev/null +++ b/resources/boost/skills/pollora-typed-meta/SKILL.md @@ -0,0 +1,135 @@ +--- +name: pollora-typed-meta +description: Declare WordPress post, term, user and comment meta as typed PHP properties with Pollora's #[Meta] attribute, then read and write them with their PHP type through Meta::of() or the Pollora models. +--- + +# Pollora Typed Meta + +## When to use this skill +Use this skill when a post type, taxonomy, user or comment needs custom fields: declaring them, reading or writing them, exposing them in REST, validating them, or checking stored values. Requires Pollora v13.34.4+; **experimental** (the API may change). + +Do not call `register_meta()`, cast `get_post_meta()` by hand or repeat a key string: declare the meta once. + +## Declaring meta + +A meta is a public typed property marked `#[Meta]` on the class that already carries `#[PostType]` or `#[Taxonomy]`. The type gives the meta type, the initial value the default, the name the key in snake_case: + +```php +use App\Enums\EventStatus; +use Carbon\CarbonImmutable; +use Pollora\Attributes\Meta; +use Pollora\Attributes\PostType; + +#[PostType('event')] +class Event +{ + #[Meta(showInRest: true, label: 'Start')] + public ?CarbonImmutable $startsAt = null; // key: starts_at + + #[Meta(showInRest: true, rules: ['min:0', 'max:5000'])] + public int $capacity = 0; + + #[Meta(showInRest: true)] + public EventStatus $status = EventStatus::Draft; // a backed enum + + #[Meta(key: '_event_internal_ref', capability: 'manage_options')] + public ?string $internalRef = null; +} +``` + +Every property needs a default or a nullable type: that is what an absent meta reads as. Run `php artisan discovery:clear` after adding a class in development. + +### Objects the project does not declare + +```php +use Pollora\Attributes\{CommentMeta, Meta, PostMeta, TermMeta, UserMeta}; + +#[PostMeta('product')] // a plugin's post type; ['post', 'page'] for several +class ProductExtras { #[Meta(showInRest: true)] public ?string $warrantyNotice = null; } + +#[TermMeta('category')] +class CategoryExtras { #[Meta(showInRest: true)] public ?string $color = null; } + +#[UserMeta] +class MemberProfile { #[Meta(showInRest: true)] public bool $newsletterOptIn = false; } + +#[CommentMeta] // every comment type: WordPress has no per-type comment meta +class ReviewMeta { #[Meta] public int $rating = 5; } +``` + +Several classes may target the same objects if their keys differ. + +### `#[Meta]` parameters + +| Parameter | Default | Effect | +|---|---|---| +| `key` | property in snake_case | Database key | +| `showInRest` | `false` | Exposes the meta in REST with a schema from its type | +| `label`, `description` | none | Editor label; `register_meta()` description | +| `sanitize` | from the type | A callable replacing it (`'wp_kses_post'` for HTML) | +| `capability` | edit right on the object | Required to write through REST; required to expose a `_` key | +| `revisions` | `false` | Versioned with post revisions (posts only) | +| `rules` | none | Laravel validation rules, the type rule implied | +| `items` | `@var list<…>` docblock | Item type of an `array`: `'string'`, `'int'`, a class… | +| `single` | `true` | On an `array`, `false` stores one row per item | +| `media` | `false` | On an `int`: an attachment ID (Block Bindings give its URL, alt, caption) | +| `public` | `false` | May be shown to anyone; required for `pollora/author-meta` | +| `control`, `group`, `hints` | from the type | Input field description for a UI driver (`config/meta.php` → `ui`) | + +### Types + +`string`, `int`, `float`, `bool`, dates (`DateTimeInterface`, Carbon — stored as ISO 8601 UTC), backed enums, `array` (with an item type), and a class with public typed scalar/date/enum properties (stored as an array, read back as an instance). Refused at discovery, with the class and property logged: union or missing types, an array without item type or of arrays, a non-backed enum, a non-nullable property without default, a `_` key in REST without `capability`, a key declared twice for the same objects. + +## Reading and writing + +```php +use Pollora\Support\Facades\Meta; + +$event = Meta::of(Event::class, $postId); // post, term, user or comment ID +$event->capacity; // int +$event->startsAt?->isFuture(); // CarbonImmutable|null +$event->starts_at; // the same meta, by its key + +$event->capacity = 250; // checked at once +$event->fill(['status' => EventStatus::Published])->save(); // update_metadata() +$event->set('startsAt', null)->save(); // null deletes a nullable meta +``` + +A wrong type throws `InvalidMetaValueException`, a broken rule `MetaValidationException`, before anything is written. `get_post_meta()` keeps returning the stored string. + +### On the models + +```php +use Pollora\Models\Post; + +class Event extends Post +{ + protected $postType = 'event'; // Post::find() then returns an Event +} + +$event->capacity; // typed attribute +Event::whereMeta('capacity', '>=', 100)->get(); // numbers compared as numbers +Event::whereMeta('subtitle', null)->get(); // meta absent +auth()->user()->newsletterOptIn; // a #[UserMeta] +``` + +Typed meta are not in `toArray()`. A collection of such models loads its meta in one query through WordPress's cache. + +## REST and validation + +With `showInRest: true` the meta appears under `meta` in the object's REST response, typed; enums list their values, dates use `date-time`. Pollora adds `custom-fields` support to a `#[PostType]` that exposes meta; for a plugin's post type targeted by `#[PostMeta]`, check it supports `custom-fields`. A REST write breaking `rules` answers 400 with the rule's message under `data.params["meta.