Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ef33f8d
feat(roles): check roles in Laravel and capabilities in REST routes
ogorzalka Oct 5, 2026
f9a1fa3
feat(roles): translate role labels with the declaring text domain
ogorzalka Oct 5, 2026
e56d6da
Merge pull request #397 from Pollora/feature/roles-laravel
ogorzalka Oct 5, 2026
d08ca7c
Merge remote-tracking branch 'origin/develop' into feature/role-labels
ogorzalka Oct 5, 2026
634dd2c
feat(meta): typed meta for users, comments and undeclared post types …
ogorzalka Oct 5, 2026
42cb024
Merge pull request #398 from Pollora/feature/role-labels
ogorzalka Oct 5, 2026
151eb61
Merge remote-tracking branch 'origin/develop' into feature/meta-owners
ogorzalka Oct 5, 2026
368277b
feat(meta): typed meta as attributes of the Pollora models
ogorzalka Oct 5, 2026
8a3ffb2
fix(meta): prime the meta cache on the first typed read instead of ov…
ogorzalka Oct 5, 2026
41087b1
Merge pull request #399 from Pollora/feature/meta-owners
ogorzalka Oct 5, 2026
68524e4
Merge remote-tracking branch 'origin/develop' into feature/meta-models
ogorzalka Oct 5, 2026
1c30e49
Merge pull request #400 from Pollora/feature/meta-models
ogorzalka Oct 5, 2026
1c89286
feat(meta): add custom-fields to a declared post type exposing meta i…
ogorzalka Oct 6, 2026
2103897
style: newline before assignment (rector)
ogorzalka Oct 6, 2026
9cf185c
feat(meta): validation rules on typed meta, for PHP and REST writes
ogorzalka Oct 6, 2026
7ca2cc2
Merge pull request #401 from Pollora/feature/meta-custom-fields
ogorzalka Oct 6, 2026
d8d7d76
feat(meta): arrays and data objects as typed meta
ogorzalka Oct 6, 2026
7b8587e
Merge remote-tracking branch 'origin/develop' into feature/meta-rules
ogorzalka Oct 6, 2026
633a358
Merge branch 'feature/meta-rules' into feature/meta-arrays
ogorzalka Oct 6, 2026
2d56eea
docs: changelog for arrays and data objects
ogorzalka Oct 6, 2026
7a684d8
feat(meta): a driver contract for the input fields of typed meta
ogorzalka Oct 6, 2026
93129bb
Merge pull request #402 from Pollora/feature/meta-rules
ogorzalka Oct 6, 2026
f352278
Merge remote-tracking branch 'origin/develop' into feature/meta-arrays
ogorzalka Oct 6, 2026
d7bd346
Merge branch 'feature/meta-arrays' into feature/meta-ui-contract
ogorzalka Oct 6, 2026
3d8ffa6
Merge pull request #403 from Pollora/feature/meta-arrays
ogorzalka Oct 6, 2026
92d7c37
Merge remote-tracking branch 'origin/develop' into feature/meta-ui-co…
ogorzalka Oct 6, 2026
7dc6121
chore: apply Rector 2.7 rules (TernaryToNullsafeCoalesce, RemoveOverr…
ogorzalka Oct 6, 2026
3dd7420
Merge pull request #404 from Pollora/feature/meta-ui-contract
ogorzalka Oct 6, 2026
3f346cb
chore: release v13.34.5
ogorzalka Oct 6, 2026
b9b75dd
Merge pull request #405 from Pollora/release/v13.34.5
ogorzalka Oct 6, 2026
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: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@ All notable changes to the Pollora framework will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.4...develop)
## [Unreleased](https://github.com/Pollora/framework/compare/v13.34.5...develop)

## [v13.34.5](https://github.com/Pollora/framework/compare/v13.34.4...v13.34.5) - 2026-10-06

### Added
- Input fields for typed meta, through a contract (**experimental**): `#[Meta(control: Control::Color, group: 'Profile', hints: ['acf' => [...]])]` describes the field in neutral terms, a control being derived from the type otherwise (`Text`, `RichText` with `sanitize: 'wp_kses_post'`, `Number`, `Toggle`, `DateTime`, `Select`). A package implements `MetaUiDriver` and registers it with `Meta::extend('acf', AcfDriver::class)`; the project picks it in `meta.ui` (none by default), and the driver receives each schema on `init`, with only the meta it supports. `Meta::schemas()`, `Meta::schemaFor('post', 'event')`, the `MetaSchemasRegistered` event, and `MetaUiDriverConformance::check($driver)` for driver authors. The framework names no field plugin
- Arrays and data objects as typed meta: an `array` property declares its item type with `items: 'int'` (or a class) or a `@var list<string>` docblock, and is stored as one serialized array or, with `single: false`, one row per item (`whereMeta()` then matches a model by one of its items). A class with public typed properties (scalars, dates, backed enums) is stored as an array, never as a PHP object, and read back as an instance; absent, it reads as an instance with its own defaults. REST publishes `items` and `properties` schemas, so WordPress refuses an item or a property of the wrong type; strings inside are sanitized like a string meta
- Typed meta: a post type declared with `#[PostType]` gets `custom-fields` support when one of its `#[Meta]` has `showInRest: true`, without which WordPress leaves `meta` out of its REST responses. A post type targeted by `#[PostMeta]` is left as it is
- Typed meta validation: `#[Meta(rules: ['min:0', 'max:5000'])]` checks a value with Laravel's validator, the type rule implied (`integer` for an int, so `max` compares numbers). A write from PHP (`Meta::of()->set()`, a model attribute) throws a `MetaValidationException` naming the meta; a REST write to a post, term, user or comment answers 400 with the message of the rule (`params` → `meta.<key>`) before anything is stored. Writes through WordPress's own functions (`update_post_meta()`) are only sanitized
- Typed meta for every object (**experimental**): `#[PostMeta('product')]` or `#[PostMeta(['post', 'page'])]` for post types the project does not declare, `#[TermMeta('category')]`, `#[UserMeta]` and `#[CommentMeta]` (every comment type: WordPress has no per-type comment meta). `Meta::of()` reads and writes them by object ID; a key two classes declare for the same objects is refused at discovery
- Typed meta on the Eloquent models (**experimental**): `Pollora\Models\Post`, `Page`, `Term`, `User` and `Comment` read the `#[Meta]` their object carries as attributes, with their PHP type (`$event->capacity`, or by key `$event->sold_out`), check a write at once and store it through `update_metadata()` when the model is saved; `whereMeta('capacity', '>=', 100)` compares numbers as numbers. A post model with `protected $postType = 'event'` is bound to its post type at discovery, so `Post::find()` returns it. A class known to carry typed meta stops eager loading the `meta` relation and primes WordPress's meta cache once per collection (one query instead of the relation); other models and undeclared keys keep Colt's behaviour
- Roles in Laravel (**experimental**), a role being named by its slug or by the class of a `#[Role]`: `hasRole()`, `assignRole()`, `removeRole()` and `roles()` on `Pollora\Models\User` (trait `HasRoles`; writes go through `WP_User`, an unknown role is refused); the `role:` route middleware (`role:event_manager,editor`, or `EnsureUserHasRole::using(EventManager::class)`), which refuses with a 403 a user who has none of the roles, an alias the application already uses being kept; `@role` accepts role classes (`@role(EventManager::class)`) and keeps the behaviour of Sage Directives' `@role` for slugs
- REST permission `Can` for `#[WpRestRoute]` and `#[Method]`: `permissionCallback: new Can('edit_posts')`, a `#[CapabilitySet]` enum case, or `new Can('edit_post', parameter: 'id')` to check a meta capability on the object in the request. `permissionCallback` now accepts a `Permission` instance as well as a class name; a refusal answers 401 to a guest and 403 to a logged-in user
- Translated role labels: `#[Role(…, textDomain: 'my-theme')]` translates the label with that domain wherever WordPress shows role names (`translate_user_role()`: users list, role dropdowns). Translation happens when the admin displays the role, through `gettext_with_context_default`, so no translation is loaded early; a label WordPress already translates is left alone

## [v13.34.4](https://github.com/Pollora/framework/compare/v13.34.3...v13.34.4) - 2026-10-05

Expand Down
6 changes: 6 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Rector\Config\RectorConfig;
use Rector\Php83\Rector\ClassMethod\AddOverrideAttributeToOverriddenMethodsRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ScalarParamTypeByMethodCallTypeRector;
use RectorLaravel\Rector\ArrayDimFetch\ServerVariableToRequestFacadeRector;
use RectorLaravel\Rector\MethodCall\ContainerBindConcreteWithClosureOnlyRector;
use RectorLaravel\Set\LaravelLevelSetList;
Expand All @@ -16,6 +17,11 @@
])
->withSkip([
AddOverrideAttributeToOverriddenMethodsRector::class,
// getAttribute($key) and setAttribute($key) override Eloquent's untyped
// signatures: typing $key would be a fatal incompatible declaration.
ScalarParamTypeByMethodCallTypeRector::class => [
__DIR__.'/src/Models/Concerns/HasTypedMeta.php',
],
ContainerBindConcreteWithClosureOnlyRector::class => [
__DIR__.'/src/Hook/Infrastructure/Providers/HookServiceProvider.php',
],
Expand Down
4 changes: 3 additions & 1 deletion src/Attributes/Attributable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace Pollora\Attributes;

use Pollora\Attributes\WpRestRoute\Permission;

/**
* Marker interface that allows a class to be interpreted for PHP attributes.
*
* Classes implementing this interface can be processed by discovery services
* to analyze and handle their attributes dynamically.
*
* @property string|null $classPermission
* @property class-string<Permission>|Permission|null $classPermission
* @property string $namespace
* @property string $route
*/
Expand Down
25 changes: 25 additions & 0 deletions src/Attributes/CommentMeta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Declares `#[Meta]` properties for comments, WooCommerce reviews included.
*
* #[CommentMeta]
* class ReviewMeta
* {
* #[Meta]
* public bool $verifiedPurchase = false;
* }
*
* The meta apply to every comment type: WordPress registers comment meta for
* all comments, it has no per-type registration.
*
* @experimental The API may still change before it is declared stable.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class CommentMeta {}
16 changes: 15 additions & 1 deletion src/Attributes/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

use Attribute;
use Illuminate\Support\Str;
use Pollora\Meta\Domain\Enums\Control;

/**
* Meta Attribute
*
* Marks a public typed property of a `#[PostType]` or `#[Taxonomy]` class as a
* Marks a public typed property of a `#[PostType]`, `#[Taxonomy]`, `#[PostMeta]`,
* `#[TermMeta]`, `#[UserMeta]` or `#[CommentMeta]` class as a
* WordPress meta. The property type gives the meta type, its initial value the
* default and its name the key:
*
Expand All @@ -37,6 +39,12 @@
* @param string|array{0: class-string|object, 1: string}|null $sanitize Callable replacing the sanitization derived from the type
* @param string|null $capability Capability required to write the meta through REST and the editor
* @param bool $revisions Versions the meta with post revisions (post types only)
* @param array<int, mixed> $rules Laravel validation rules, checked on writes from PHP and REST
* @param bool $single On an `array` property, false stores one row per item instead of one serialized array
* @param string|null $items On an `array` property, the item type: `'string'`, `'int'`, `'float'`, `'bool'` or a class. Defaults to the `@var list<…>` docblock
* @param Control|null $control The input a UI driver should build. Defaults to one derived from the type
* @param string|null $group The group of fields a UI driver puts the meta in
* @param array<string, mixed> $hints Options passed as they are to UI drivers, by driver: `['acf' => ['wrapper' => ['width' => 50]]]`
*/
public function __construct(
public ?string $key = null,
Expand All @@ -46,6 +54,12 @@ public function __construct(
public string|array|null $sanitize = null,
public ?string $capability = null,
public bool $revisions = false,
public array $rules = [],
public bool $single = true,
public ?string $items = null,
public ?Control $control = null,
public ?string $group = null,
public array $hints = [],
) {}

/**
Expand Down
37 changes: 37 additions & 0 deletions src/Attributes/PostMeta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Declares `#[Meta]` properties for post types the class does not declare: a
* core post type, a plugin's (WooCommerce's `product`), or several at once.
*
* #[PostMeta('product')]
* class ProductExtras
* {
* #[Meta(showInRest: true)]
* public ?string $warrantyNotice = null;
* }
*
* For a post type declared with `#[PostType]`, put `#[Meta]` on that class.
*
* @experimental The API may still change before it is declared stable.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class PostMeta
{
/** @var list<string> */
public array $postTypes;

/**
* @param string|list<string> $postTypes The post type slugs the meta belong to
*/
public function __construct(string|array $postTypes)
{
$this->postTypes = array_values((array) $postTypes);
}
}
2 changes: 2 additions & 0 deletions src/Attributes/Role.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@
* @param string|null $label The name shown in the admin. Defaults to the class name, humanized
* @param string|null $inherits A role whose capabilities are the starting point: a slug, or the class of a #[Role]
* @param bool $allowSensitive Required to grant a sensitive capability (manage_options, edit_users…)
* @param string|null $textDomain The text domain the label is translated with in the admin, such as the declaring theme's or plugin's
*/
public function __construct(
public string $slug,
public ?string $label = null,
public ?string $inherits = null,
public bool $allowSensitive = false,
public ?string $textDomain = null,
) {}
}
37 changes: 37 additions & 0 deletions src/Attributes/TermMeta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Declares `#[Meta]` properties for taxonomies the class does not declare: a
* core taxonomy (`category`), a plugin's, or several at once.
*
* #[TermMeta('category')]
* class CategoryExtras
* {
* #[Meta(showInRest: true)]
* public ?string $color = null;
* }
*
* For a taxonomy declared with `#[Taxonomy]`, put `#[Meta]` on that class.
*
* @experimental The API may still change before it is declared stable.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class TermMeta
{
/** @var list<string> */
public array $taxonomies;

/**
* @param string|list<string> $taxonomies The taxonomy slugs the meta belong to
*/
public function __construct(string|array $taxonomies)
{
$this->taxonomies = array_values((array) $taxonomies);
}
}
25 changes: 25 additions & 0 deletions src/Attributes/UserMeta.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Pollora\Attributes;

use Attribute;

/**
* Declares `#[Meta]` properties for users. Several classes may declare user
* meta, one per module for instance, as long as their keys differ.
*
* #[UserMeta]
* class MemberProfile
* {
* #[Meta(showInRest: true)]
* public bool $newsletterOptIn = false;
* }
*
* Meta::of(MemberProfile::class, $userId)->newsletterOptIn;
*
* @experimental The API may still change before it is declared stable.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class UserMeta {}
5 changes: 3 additions & 2 deletions src/Attributes/WpRestRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Attribute;
use Pollora\Attributes\Contracts\HandlesAttributes;
use Pollora\Attributes\WpRestRoute\Permission;
use ReflectionClass;
use ReflectionMethod;

Expand All @@ -20,12 +21,12 @@ class WpRestRoute implements HandlesAttributes
*
* @param string $namespace The namespace for the REST API route (e.g., "my-plugin/v1").
* @param string $route The specific route within the namespace (e.g., "/items").
* @param string|null $permissionCallback Optional callback method name to check permissions for the route.
* @param class-string<Permission>|Permission|null $permissionCallback The permission for the route: a Permission class, or an instance such as `new Can('edit_posts')`.
*/
public function __construct(
public readonly string $namespace,
public readonly string $route,
public readonly ?string $permissionCallback = null
public readonly string|Permission|null $permissionCallback = null
) {}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/Attributes/WpRestRoute/Method.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ class Method implements HandlesAttributes
* Constructor for the Method attribute.
*
* @param array|string $methods The HTTP methods allowed for this route.
* @param string|null $permissionCallback The callback function to check permissions for the route.
* @param class-string<Permission>|Permission|null $permissionCallback The permission for this method, replacing the route's: a Permission class, or an instance such as `new Can('edit_posts')`.
*
* @throws InvalidArgumentException If an invalid HTTP method is provided.
*/
public function __construct(
public array|string $methods,
public ?string $permissionCallback = null
public string|Permission|null $permissionCallback = null
) {
$this->methods = is_array($methods) ? $methods : [$methods];
$this->validateMethods();
Expand Down Expand Up @@ -161,21 +161,21 @@ private function extractArgsFromRoute(string $route): array
/**
* Resolves and executes the permission callback.
*
* @param string|null $permissionCallback The permission class to use
* @param class-string<Permission>|Permission|null $permissionCallback The permission class or instance to use
* @return callable The permission function
*/
private function resolvePermissionCallback(?string $permissionCallback): callable
private function resolvePermissionCallback(string|Permission|null $permissionCallback): callable
{
if ($permissionCallback === null) {
return '__return_true';
}

if (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class)) {
if (is_string($permissionCallback) && (! class_exists($permissionCallback) || ! is_subclass_of($permissionCallback, Permission::class))) {
return fn (): WP_Error => new WP_Error('rest_forbidden', __('Invalid permission handler.'), ['status' => 403]);
}

return WpGlobals::wrap(function (WP_REST_Request $request) use ($permissionCallback): bool|WP_Error {
$permissionInstance = new $permissionCallback;
$permissionInstance = is_string($permissionCallback) ? new $permissionCallback : $permissionCallback;

return $permissionInstance->allow($request);
});
Expand Down
2 changes: 0 additions & 2 deletions src/Discovery/Infrastructure/Services/ReflectionCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,6 @@ private function buildMethodAttributeCache(string $className, ?string $attribute
$methodsWithAttributes = [];

foreach ($this->getPublicMethods($className) as $method) {
$hasMatchingAttribute = false;

if ($attributeClass === null) {
// Get all methods with any attributes
$hasMatchingAttribute = ! empty($method->getAttributes());
Expand Down
55 changes: 51 additions & 4 deletions src/Meta/Application/Services/MetaAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@

namespace Pollora\Meta\Application\Services;

use Closure;
use Illuminate\Contracts\Container\Container;
use LogicException;
use Pollora\Meta\Domain\Contracts\MetaStoreInterface;
use Pollora\Meta\Domain\Contracts\MetaUiDriver;
use Pollora\Meta\Domain\Contracts\MetaValidatorInterface;
use Pollora\Meta\Domain\Enums\MetaObjectType;
use Pollora\Meta\Domain\Exceptions\InvalidMetaValueException;
use Pollora\Meta\Domain\Models\MetaDefinition;
use Pollora\Meta\Domain\Models\MetaRecord;
use Pollora\Meta\Domain\Models\MetaSchema;
use Pollora\Meta\Domain\Services\MetaValueCaster;
use Psr\Log\LoggerInterface;

Expand All @@ -27,17 +34,57 @@ public function __construct(
private MetaValueCaster $caster,
private bool $debug = false,
private ?LoggerInterface $logger = null,
private ?MetaValidatorInterface $validator = null,
private ?MetaUiDrivers $drivers = null,
) {}

/**
* @param class-string $class The `#[PostType]` or `#[Taxonomy]` class declaring the meta
* @param int $objectId The post or term ID
* @param class-string $class The class declaring the meta
* @param int $objectId The post, term, user or comment ID
*/
public function of(string $class, int $objectId): MetaRecord
{
$schema = $this->schemas->forClass($class) ?? $this->builder->build($class);
return $this->record($this->schemas->forClass($class) ?? $this->builder->build($class), $objectId);
}

/**
* Every typed meta schema of the project.
*
* @return list<MetaSchema>
*/
public function schemas(): array
{
return $this->schemas->all();
}

return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...));
/**
* The schemas whose meta an object carries: `Meta::schemaFor('post', 'event')`,
* `Meta::schemaFor('user')`.
*
* @return list<MetaSchema>
*/
public function schemaFor(MetaObjectType|string $objectType, ?string $subtype = null): array
{
return $this->schemas->forObject($objectType instanceof MetaObjectType ? $objectType : MetaObjectType::from($objectType), $subtype);
}

/**
* Registers a UI driver, from a package's service provider:
* `Meta::extend('acf', AcfDriver::class)`.
*
* @param class-string<MetaUiDriver>|Closure(Container): MetaUiDriver $driver
*/
public function extend(string $name, string|Closure $driver): void
{
($this->drivers ?? throw new LogicException('Meta UI drivers are not available.'))->extend($name, $driver);
}

/**
* The typed meta of a schema on one object.
*/
public function record(MetaSchema $schema, int $objectId): MetaRecord
{
return new MetaRecord($schema, $objectId, $this->store, $this->caster, $this->handleUnreadable(...), $this->validator);
}

private function handleUnreadable(InvalidMetaValueException $exception, MetaDefinition $definition): mixed
Expand Down
Loading
Loading