From 1423325b187725953241061b3d7812c2460a9350 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:23:33 +0200 Subject: [PATCH 1/5] Add attribute-based model data accessors --- formwork/src/Cms/Site.php | 32 ++-- formwork/src/Data/Attributes/Getter.php | 17 ++ formwork/src/Data/Attributes/Setter.php | 16 ++ formwork/src/Data/Traits/DataAccessors.php | 187 ++++++++++++++++++++ formwork/src/Data/Traits/DataArrayable.php | 15 +- formwork/src/Data/Traits/DataGetter.php | 12 +- formwork/src/Data/Traits/DataSetter.php | 19 ++ formwork/src/Fields/Field.php | 6 +- formwork/src/Files/File.php | 32 ++-- formwork/src/Images/Image.php | 28 +-- formwork/src/Model/Model.php | 71 +++++++- formwork/src/Pages/Page.php | 47 ++++- formwork/src/Pages/Traits/PageStatus.php | 6 +- formwork/src/Pages/Traits/PageTraversal.php | 7 + formwork/src/Pages/Traits/PageUid.php | 4 +- 15 files changed, 428 insertions(+), 71 deletions(-) create mode 100644 formwork/src/Data/Attributes/Getter.php create mode 100644 formwork/src/Data/Attributes/Setter.php create mode 100644 formwork/src/Data/Traits/DataAccessors.php diff --git a/formwork/src/Cms/Site.php b/formwork/src/Cms/Site.php index ba320708c..790144745 100644 --- a/formwork/src/Cms/Site.php +++ b/formwork/src/Cms/Site.php @@ -3,12 +3,13 @@ namespace Formwork\Cms; use Formwork\Config\Config; +use Formwork\Data\Attributes\Getter; +use Formwork\Data\Attributes\Setter; use Formwork\Files\FileCollection; use Formwork\Files\FileFactory; use Formwork\Languages\Languages; use Formwork\Languages\LanguagesFactory; use Formwork\Metadata\MetadataCollection; -use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Model\Model; use Formwork\Pages\ContentFile; use Formwork\Pages\Exceptions\PageNotFoundException; @@ -47,25 +48,21 @@ class Site extends Model implements Stringable /** * Site content file */ - #[ReadonlyModelProperty] protected ?ContentFile $contentFile = null; /** * Site route */ - #[ReadonlyModelProperty] protected ?string $route = '/'; /** * Site canonical route */ - #[ReadonlyModelProperty] protected ?string $canonicalRoute = null; /** * Site slug */ - #[ReadonlyModelProperty] protected ?string $slug = ''; /** @@ -76,19 +73,16 @@ class Site extends Model implements Stringable /** * Site templates */ - #[ReadonlyModelProperty] protected Templates $templates; /** * Site users */ - #[ReadonlyModelProperty] protected Users $users; /** * Site last modified time */ - #[ReadonlyModelProperty] protected int $lastModifiedTime; /** @@ -101,7 +95,6 @@ class Site extends Model implements Stringable * * @var array */ - #[ReadonlyModelProperty] protected array $storage = []; /** @@ -119,7 +112,6 @@ class Site extends Model implements Stringable /** * Site files */ - #[ReadonlyModelProperty] protected FileCollection $files; /** @@ -163,6 +155,7 @@ public function inclusiveSiblings(): PageCollection /** * Get site path */ + #[Getter] public function path(): ?string { return $this->path; @@ -171,11 +164,13 @@ public function path(): ?string /** * Get site filename */ + #[Getter] public function contentFile(): ?ContentFile { return $this->contentFile; } + #[Getter] public function contentPath(): ?string { return $this->contentPath; @@ -189,6 +184,7 @@ public function contentRelativePath(): ?string /** * Get site route */ + #[Getter] public function route(): ?string { return $this->route; @@ -197,6 +193,7 @@ public function route(): ?string /** * Get site canonical route */ + #[Getter] public function canonicalRoute(): ?string { return $this->canonicalRoute; @@ -205,6 +202,7 @@ public function canonicalRoute(): ?string /** * Get site slug */ + #[Getter] public function slug(): ?string { return $this->slug; @@ -213,6 +211,7 @@ public function slug(): ?string /** * Get site languages */ + #[Getter] public function languages(): Languages { return $this->languages; @@ -221,6 +220,7 @@ public function languages(): Languages /** * Get site templates */ + #[Getter] public function templates(): Templates { return $this->templates; @@ -229,6 +229,7 @@ public function templates(): Templates /** * Get site users */ + #[Getter] public function users(): Users { return $this->users; @@ -245,6 +246,7 @@ public function schemes(): Schemes /** * Get the current page of the site */ + #[Getter] public function currentPage(): ?Page { return $this->currentPage; @@ -255,6 +257,7 @@ public function currentPage(): ?Page * * @return array */ + #[Getter] public function routeAliases(): array { return $this->routeAliases; @@ -263,6 +266,7 @@ public function routeAliases(): array /** * Get site metadata */ + #[Getter] public function metadata(): MetadataCollection { if (isset($this->metadata)) { @@ -282,6 +286,7 @@ public function metadata(): MetadataCollection /** * Get site last modified time */ + #[Getter] public function lastModifiedTime(): ?int { if ($this->contentPath === null) { @@ -462,6 +467,7 @@ public function resolveRouteAlias(string $route): ?string /** * Set and return site current page */ + #[Setter] public function setCurrentPage(Page $page): Page { return $this->currentPage = $page; @@ -470,6 +476,7 @@ public function setCurrentPage(Page $page): Page /** * Get site files */ + #[Getter] public function files(): FileCollection { if (isset($this->files)) { @@ -517,6 +524,7 @@ public function load(): void * * @param array{available: list, httpPreferred: bool, default?: string} $config */ + #[Setter] protected function setLanguages(array $config): void { $this->data['languages'] = $config; @@ -530,6 +538,7 @@ protected function setLanguages(array $config): void /** * @param array $metadata */ + #[Setter] protected function setMetadata(array $metadata): void { $this->data['metadata'] = $metadata; @@ -538,6 +547,7 @@ protected function setMetadata(array $metadata): void /** * Set site path */ + #[Setter] protected function setPath(string $path): void { $this->path = $this->data['path'] = $path; @@ -546,6 +556,7 @@ protected function setPath(string $path): void /** * Set site content path */ + #[Setter] protected function setContentPath(string $path): void { $this->contentPath = FileSystem::normalizePath($path . '/'); @@ -556,6 +567,7 @@ protected function setContentPath(string $path): void * * @param array $aliases */ + #[Setter] protected function setRouteAliases(array $aliases): void { foreach ($aliases as $from => $to) { diff --git a/formwork/src/Data/Attributes/Getter.php b/formwork/src/Data/Attributes/Getter.php new file mode 100644 index 000000000..970d932cf --- /dev/null +++ b/formwork/src/Data/Attributes/Getter.php @@ -0,0 +1,17 @@ +, + * setters: array + * }> + */ + private static array $dataAccessors = []; + + /** + * Get registered data accessors + * + * @return array{ + * getters: array, + * setters: array + * } + */ + private function dataAccessors(): array + { + return self::$dataAccessors[static::class] ??= $this->resolveDataAccessors(); + } + + /** + * Get registered data getters + * + * @return array + */ + private function dataGetters(): array + { + return $this->dataAccessors()['getters']; + } + + /** + * Get registered data setters + * + * @return array + */ + private function dataSetters(): array + { + return $this->dataAccessors()['setters']; + } + + /** + * Resolve data accessors + * + * @return array{ + * getters: array, + * setters: array + * } + */ + private function resolveDataAccessors(): array + { + $reflectionClass = new ReflectionClass($this); + + $accessors = [ + 'getters' => [], + 'setters' => [], + ]; + + foreach ($reflectionClass->getProperties() as $property) { + $name = $property->getName(); + + foreach ($property->getAttributes(Getter::class) as $attribute) { + $attribute = $attribute->newInstance(); + $this->registerDataGetter( + $accessors['getters'], + $attribute->key ?? $name, + 'property', + $name, + $attribute->export + ); + } + + foreach ($property->getAttributes(Setter::class) as $attribute) { + $key = $attribute->newInstance()->key ?? $name; + $this->registerDataSetter( + $accessors['setters'], + $key, + 'property', + $name + ); + } + } + + foreach ($reflectionClass->getMethods() as $method) { + $name = $method->getName(); + + foreach ($method->getAttributes(Getter::class) as $attribute) { + $attribute = $attribute->newInstance(); + $this->registerDataGetter( + $accessors['getters'], + $attribute->key ?? $this->resolveDataGetterKey($method), + 'method', + $name, + $attribute->export + ); + } + + foreach ($method->getAttributes(Setter::class) as $attribute) { + $attribute = $attribute->newInstance(); + $this->registerDataSetter( + $accessors['setters'], + $attribute->key ?? $this->resolveDataSetterKey($method), + 'method', + $name + ); + } + } + + return $accessors; + } + + /** + * @param array $getters + * @param 'method'|'property' $type + */ + private function registerDataGetter(array &$getters, string $key, string $type, string $name, bool $export): void + { + if (isset($getters[$key])) { + throw new LogicException(sprintf('Multiple getters registered for data key "%s".', $key)); + } + $getters[$key] = ['type' => $type, 'name' => $name, 'export' => $export]; + } + + /** + * @param array $setters + * @param 'method'|'property' $type + */ + private function registerDataSetter(array &$setters, string $key, string $type, string $name): void + { + if (isset($setters[$key])) { + throw new LogicException(sprintf('Multiple setters registered for data key "%s".', $key)); + } + $setters[$key] = ['type' => $type, 'name' => $name]; + } + + /** + * Resolve a data getter key from a method + */ + private function resolveDataGetterKey(ReflectionMethod $reflectionMethod): string + { + $name = $reflectionMethod->getName(); + + foreach (['get', 'is'] as $prefix) { + $length = strlen($prefix); + + if (Str::startsWith($name, $prefix) && isset($name[$length]) && ctype_upper($name[$length])) { + return lcfirst(Str::after($name, $prefix)); + } + } + + return $name; + } + + /** + * Resolve a data setter key from a method + */ + private function resolveDataSetterKey(ReflectionMethod $reflectionMethod): string + { + $name = $reflectionMethod->getName(); + $prefix = 'set'; + $length = strlen($prefix); + + if (Str::startsWith($name, $prefix) && isset($name[$length]) && ctype_upper($name[$length])) { + return lcfirst(Str::after($name, $prefix)); + } + + return $name; + } +} diff --git a/formwork/src/Data/Traits/DataArrayable.php b/formwork/src/Data/Traits/DataArrayable.php index 024fac1ca..28de7e989 100644 --- a/formwork/src/Data/Traits/DataArrayable.php +++ b/formwork/src/Data/Traits/DataArrayable.php @@ -9,6 +9,8 @@ */ trait DataArrayable { + use DataAccessors; + /** * @var array */ @@ -19,6 +21,17 @@ trait DataArrayable */ public function toArray(): array { - return $this->data; + $data = []; + + foreach ($this->dataGetters() as $key => $accessor) { + if ($accessor['export']) { + $data[$key] = match ($accessor['type']) { + 'method' => $this->{$accessor['name']}(), + 'property' => $this->{$accessor['name']}, + }; + } + } + + return $data + $this->data; } } diff --git a/formwork/src/Data/Traits/DataGetter.php b/formwork/src/Data/Traits/DataGetter.php index 804d28d93..34d858d4b 100644 --- a/formwork/src/Data/Traits/DataGetter.php +++ b/formwork/src/Data/Traits/DataGetter.php @@ -9,6 +9,8 @@ */ trait DataGetter { + use DataAccessors; + /** * @var TData */ @@ -21,7 +23,8 @@ trait DataGetter */ public function has(string $key): bool { - return Arr::has($this->data, $key); + return isset($this->dataGetters()[$key]) + || Arr::has($this->data, $key); } /** @@ -37,6 +40,13 @@ public function has(string $key): bool */ public function get(string $key, mixed $default = null): mixed { + if ($getter = $this->dataGetters()[$key] ?? null) { + return match ($getter['type']) { + 'property' => $this->{$getter['name']}, + 'method' => $this->{$getter['name']}(), + }; + } + return Arr::get($this->data, $key, $default); } } diff --git a/formwork/src/Data/Traits/DataSetter.php b/formwork/src/Data/Traits/DataSetter.php index 3973797c6..9a4c434f6 100644 --- a/formwork/src/Data/Traits/DataSetter.php +++ b/formwork/src/Data/Traits/DataSetter.php @@ -3,12 +3,15 @@ namespace Formwork\Data\Traits; use Formwork\Utils\Arr; +use LogicException; /** * @template TData of array = array */ trait DataSetter { + use DataAccessors; + /** * @var TData */ @@ -22,6 +25,18 @@ trait DataSetter */ public function set(string $key, mixed $value): void { + if ($setter = $this->dataSetters()[$key] ?? null) { + match ($setter['type']) { + 'property' => $this->{$setter['name']} = $value, + 'method' => $this->{$setter['name']}($value), + }; + return; + } + + if (isset($this->dataGetters()[$key])) { + throw new LogicException(sprintf('Cannot set getter-only key %s', $key)); + } + Arr::set($this->data, $key, $value); } @@ -32,6 +47,10 @@ public function set(string $key, mixed $value): void */ public function remove(string $key): void { + if (isset($this->dataGetters()[$key]) || isset($this->dataSetters()[$key])) { + throw new LogicException(sprintf('Cannot remove getter- or setter-backed key %s', $key)); + } + Arr::remove($this->data, $key); } } diff --git a/formwork/src/Fields/Field.php b/formwork/src/Fields/Field.php index bbd630a2d..f3ea3fb84 100644 --- a/formwork/src/Fields/Field.php +++ b/formwork/src/Fields/Field.php @@ -3,6 +3,7 @@ namespace Formwork\Fields; use Closure; +use Formwork\Data\Attributes\Getter; use Formwork\Data\Contracts\Arrayable; use Formwork\Data\Traits\DataArrayable; use Formwork\Data\Traits\DataMultipleGetter; @@ -78,6 +79,7 @@ public function __toString(): string /** * Get field name */ + #[Getter] public function name(): string { return $this->name; @@ -96,7 +98,7 @@ public function parent(): ?FieldCollection */ public function formName(): string { - return $this->get('formName', Str::dotNotationToBrackets($this->name())); + return $this->get('formName'); } /** @@ -168,7 +170,7 @@ public function isDisabled(): bool */ public function isVisible(): bool { - return $this->is('visible', true); + return $this->is('visible'); } /** diff --git a/formwork/src/Files/File.php b/formwork/src/Files/File.php index eb4a93ce3..f5f0cf346 100644 --- a/formwork/src/Files/File.php +++ b/formwork/src/Files/File.php @@ -2,9 +2,9 @@ namespace Formwork\Files; +use Formwork\Data\Attributes\Getter; use Formwork\Data\Contracts\Arrayable; use Formwork\Files\Exceptions\FileUriGenerationException; -use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Model\Model; use Formwork\Schemes\Scheme; use Formwork\Utils\FileSystem; @@ -28,19 +28,16 @@ class File extends Model implements Arrayable, Stringable /** * File name */ - #[ReadonlyModelProperty] protected string $name; /** * File extension */ - #[ReadonlyModelProperty] protected string $extension; /** * File MIME type */ - #[ReadonlyModelProperty] protected string $mimeType; /** @@ -48,33 +45,29 @@ class File extends Model implements Arrayable, Stringable * * @var 'archive'|'audio'|'document'|'image'|'pdf'|'presentation'|'spreadsheet'|'text'|'video'|null */ - #[ReadonlyModelProperty] protected ?string $type = null; /** * File size in a human-readable format */ - #[ReadonlyModelProperty] protected string $size; /** * File last modified time */ - #[ReadonlyModelProperty] protected int $lastModifiedTime; /** * File hash */ - #[ReadonlyModelProperty] protected string $hash; /** * File content hash */ - #[ReadonlyModelProperty] protected string $contentHash; + #[Getter(export: false)] protected FileUriGenerator $uriGenerator; /** @@ -95,6 +88,7 @@ public function __toString(): string /** * Get file path */ + #[Getter] public function path(): string { return $this->path; @@ -103,6 +97,7 @@ public function path(): string /** * Get file name */ + #[Getter] public function name(): string { return $this->name; @@ -111,6 +106,7 @@ public function name(): string /** * Get file extension */ + #[Getter] public function extension(): string { return $this->extension; @@ -119,6 +115,7 @@ public function extension(): string /** * Get file MIME type */ + #[Getter] public function mimeType(): string { return $this->mimeType ??= FileSystem::mimeType($this->path); @@ -131,6 +128,7 @@ public function mimeType(): string * * @return 'archive'|'audio'|'document'|'image'|'pdf'|'presentation'|'spreadsheet'|'text'|'video'|null */ + #[Getter] public function type(): ?string { if ($this->type !== null) { @@ -169,6 +167,7 @@ public function type(): ?string /** * Get file size */ + #[Getter] public function size(): string { return $this->size ??= FileSystem::formatSize(FileSystem::fileSize($this->path)); @@ -177,6 +176,7 @@ public function size(): string /** * Get file last modified time */ + #[Getter] public function lastModifiedTime(): int { return $this->lastModifiedTime ??= FileSystem::lastModifiedTime($this->path); @@ -185,6 +185,7 @@ public function lastModifiedTime(): int /** * Get file hash */ + #[Getter(export: false)] public function hash(): string { return $this->hash ??= hash('sha256', "{$this->path}:{$this->lastModifiedTime()}"); @@ -195,6 +196,7 @@ public function hash(): string * * @throws RuntimeException If file hash calculation fails */ + #[Getter(export: false)] public function contentHash(): string { if (isset($this->contentHash)) { @@ -242,18 +244,6 @@ public function absoluteUri(): string return $this->uriGenerator->generateAbsolute($this); } - public function toArray(): array - { - return [ - 'path' => $this->path, - 'name' => $this->name, - 'extension' => $this->extension, - 'type' => $this->type(), - 'size' => $this->size(), - 'lastModifiedTime' => $this->lastModifiedTime(), - ]; - } - /** * Set file scheme */ diff --git a/formwork/src/Images/Image.php b/formwork/src/Images/Image.php index ddaa8c087..f47642fa2 100644 --- a/formwork/src/Images/Image.php +++ b/formwork/src/Images/Image.php @@ -2,6 +2,8 @@ namespace Formwork\Images; +use Formwork\Data\Attributes\Getter; +use Formwork\Data\Attributes\Setter; use Formwork\Files\File; use Formwork\Images\ColorProfile\ColorProfile; use Formwork\Images\Exception\ImageException; @@ -37,7 +39,6 @@ use Formwork\Images\Transform\Sharpen; use Formwork\Images\Transform\Smoothen; use Formwork\Images\Transform\TransformCollection; -use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Utils\FileSystem; use Formwork\Utils\MimeType; @@ -47,16 +48,14 @@ class Image extends File protected const string MODEL_IDENTIFIER = 'image'; - #[ReadonlyModelProperty] + #[Getter(export: false)] protected AbstractHandler $handler; - #[ReadonlyModelProperty] protected ImageInfo $info; - #[ReadonlyModelProperty] + #[Getter(export: false)] protected TransformCollection $transforms; - #[ReadonlyModelProperty] protected ?string $type = 'image'; /** @@ -64,12 +63,14 @@ class Image extends File */ public function __construct( string $path, + #[Getter(export: false)] protected array $options, ) { parent::__construct($path); $this->transforms = new TransformCollection(); } + #[Getter] public function path(): string { return $this->process()->path; @@ -80,6 +81,7 @@ public function path(): string * * @throws ImageException If image info cannot be determined */ + #[Getter] public function mimeType(): string { if (!isset($this->mimeType)) { @@ -300,6 +302,7 @@ public function hasColorProfile(): bool * * @throws UnsupportedFeatureException If the image does not support color profiles */ + #[Getter] public function getColorProfile(): ?ColorProfile { return $this->handler()->getColorProfile(); @@ -310,6 +313,7 @@ public function getColorProfile(): ?ColorProfile * * @throws UnsupportedFeatureException If the image does not support color profiles */ + #[Setter] public function setColorProfile(ColorProfile $colorProfile): void { $this->handler()->setColorProfile($colorProfile); @@ -338,6 +342,7 @@ public function hasExifData(): bool * * @throws UnsupportedFeatureException If the image does not support EXIF data */ + #[Getter] public function getExifData(): ?ExifData { return $this->handler()->getExifData(); @@ -348,6 +353,7 @@ public function getExifData(): ?ExifData * * @throws UnsupportedFeatureException If the image does not support EXIF data */ + #[Setter] public function setExifData(ExifData $exifData): void { $this->handler()->setExifData($exifData); @@ -487,6 +493,7 @@ public function saveAs(string $path, ?string $mimeType = null): void /** * Get image info as an array */ + #[Getter] public function info(): ImageInfo { return $this->handler()->getInfo(); @@ -498,6 +505,7 @@ public function info(): ImageInfo * * @since 2.1.0 */ + #[Getter] public function width(): int { return $this->process()->info()->width(); @@ -509,20 +517,12 @@ public function width(): int * * @since 2.1.0 */ + #[Getter] public function height(): int { return $this->process()->info()->height(); } - public function toArray(): array - { - return [ - ...parent::toArray(), - 'imageInfo' => $this->info()->toArray(), - 'uri' => $this->uri(), - ]; - } - /** * Get image hash based on its path, transforms and format * diff --git a/formwork/src/Model/Model.php b/formwork/src/Model/Model.php index 16bc1718a..369671f1d 100644 --- a/formwork/src/Model/Model.php +++ b/formwork/src/Model/Model.php @@ -4,6 +4,8 @@ use BadMethodCallException; use Formwork\Cms\App; +use Formwork\Data\Attributes\Getter; +use Formwork\Data\Attributes\Setter; use Formwork\Data\Contracts\Arrayable; use Formwork\Data\Traits\DataMultipleGetter; use Formwork\Data\Traits\DataMultipleSetter; @@ -12,12 +14,23 @@ use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Schemes\Scheme; use Formwork\Utils\Arr; +use LogicException; use ReflectionAttribute; use ReflectionProperty; +/** + * @template TData of array = array + */ class Model implements Arrayable { + /** + * @use DataMultipleGetter + */ use DataMultipleGetter; + + /** + * @use DataMultipleSetter + */ use DataMultipleSetter; /** @@ -30,25 +43,21 @@ class Model implements Arrayable * * @var array */ - #[ReadonlyModelProperty] protected array $data = []; /** * Application instance */ - #[ReadonlyModelProperty] protected App $app; /** * Model scheme */ - #[ReadonlyModelProperty] protected Scheme $scheme; /** * Model fields */ - #[ReadonlyModelProperty] protected FieldCollection $fields; /** @@ -60,7 +69,7 @@ public function __call(string $name, array $arguments): mixed return $this->get($name); } - throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + throw new LogicException(sprintf('Call to undefined method %s::%s()', static::class, $name)); } /** @@ -74,6 +83,7 @@ public function getModelIdentifier(): string /** * Return the model scheme */ + #[Getter] public function scheme(): Scheme { return $this->scheme; @@ -82,6 +92,7 @@ public function scheme(): Scheme /** * Return the model fields */ + #[Getter] public function fields(): FieldCollection { return $this->fields; @@ -92,7 +103,11 @@ public function fields(): FieldCollection */ public function has(string $key): bool { + if (isset($this->dataGetters()[$key])) { + return true; + } if (property_exists($this, $key) && !(new ReflectionProperty($this, $key))->isPromoted()) { + trigger_error(sprintf('Checking the existence of the %s::$%s property implicitly with the has() method is deprecated since Formwork 2.4.0. Add the %s attribute to the property to explicitly allow this behavior', static::class, $key, Getter::class), E_USER_DEPRECATED); return true; } if ($this->fields->has($key)) { @@ -106,14 +121,23 @@ public function has(string $key): bool */ public function get(string $key, mixed $default = null): mixed { + if ($getter = $this->dataGetters()[$key] ?? null) { + return match ($getter['type']) { + 'property' => $this->{$getter['name']}, + 'method' => $this->{$getter['name']}(), + }; + } + // Get values from property if (property_exists($this, $key) && !(new ReflectionProperty($this, $key))->isPromoted()) { // Call getter method if exists. We check property existence before // to avoid using get to call methods arbitrarily if (method_exists($this, $key)) { + trigger_error(sprintf('Using the implicit getter method %s::%s() is deprecated since Formwork 2.4.0. Add the %s attribute to the method to make it explicit', static::class, $key, Getter::class), E_USER_DEPRECATED); return $this->{$key}(); } + trigger_error(sprintf('Getting the %s::$%s property implicitly with the get() method is deprecated since Formwork 2.4.0. Add the %s attribute to the property to explicitly allow this behavior', static::class, $key, Getter::class), E_USER_DEPRECATED); return $this->{$key} ?? $default; } @@ -143,6 +167,18 @@ public function get(string $key, mixed $default = null): mixed */ public function set(string $key, mixed $value): void { + if ($setter = $this->dataSetters()[$key] ?? null) { + match ($setter['type']) { + 'property' => $this->{$setter['name']} = $value, + 'method' => $this->{$setter['name']}($value), + }; + return; + } + + if (isset($this->dataGetters()[$key])) { + throw new LogicException(sprintf('Cannot set getter-only key %s', $key)); + } + if (property_exists($this, $key) && !(new ReflectionProperty($this, $key))->isPromoted()) { if ($this->isReadonly($key)) { throw new BadMethodCallException(sprintf('Cannot set readonly model property %s::$%s', static::class, $key)); @@ -150,10 +186,12 @@ public function set(string $key, mixed $value): void // If defined use a setter if (method_exists($this, $setter = 'set' . ucfirst($key))) { + trigger_error(sprintf('Using the implicit setter method %s::set%s() is deprecated since Formwork 2.4.0. Add the %s attribute to the method to make it explicit', static::class, ucfirst($key), Setter::class), E_USER_DEPRECATED); $this->{$setter}($value); return; } + trigger_error(sprintf('Setting the %s::$%s property implicitly with the set() method is deprecated since Formwork 2.4.0. Add the %s attribute to the property %s::$%s to explicitly allow this behavior', static::class, $key, Setter::class, static::class, $key), E_USER_DEPRECATED); $this->{$key} = $value; return; } @@ -179,12 +217,29 @@ public function set(string $key, mixed $value): void */ public function toArray(): array { - $properties = array_keys(get_class_vars(static::class)); + $data = []; + + foreach ($this->dataGetters() as $key => $accessor) { + if ($accessor['export']) { + $data[$key] = match ($accessor['type']) { + 'method' => $this->{$accessor['name']}(), + 'property' => $this->{$accessor['name']}, + }; + } + } - Arr::pull($properties, 'data'); + $properties = array_diff( + array_keys(get_class_vars(static::class)), + array_keys($this->dataGetters()), + ['data', 'dataAccessors'] + ); + + if (count($properties) > 0) { + trigger_error(sprintf('Getting the following properties implicitly with the toArray() method is deprecated since Formwork 2.4.0: %s. Add the %s(export: true) attribute to the properties to explicitly allow this behavior', implode(', ', array_map(fn($property) => static::class . '::$' . $property, $properties)), Getter::class), E_USER_DEPRECATED); + } /** @var list $properties */ - $data = [...$this->data, ...$this->getMultiple($properties)]; + $data += [...$this->data, ...$this->getMultiple($properties)]; ksort($data); diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index eef5ba57d..c079b6979 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -4,6 +4,8 @@ use Formwork\Cms\App; use Formwork\Cms\Site; +use Formwork\Data\Attributes\Getter; +use Formwork\Data\Attributes\Setter; use Formwork\Data\Exceptions\InvalidValueException; use Formwork\Files\File; use Formwork\Files\FileCollection; @@ -12,7 +14,6 @@ use Formwork\Languages\Language; use Formwork\Languages\Languages; use Formwork\Metadata\MetadataCollection; -use Formwork\Model\Attributes\ReadonlyModelProperty; use Formwork\Model\Model; use Formwork\Pages\Events\PageAfterDeleteEvent; use Formwork\Pages\Events\PageAfterDuplicateEvent; @@ -95,13 +96,11 @@ class Page extends Model implements Stringable /** * Page path relative to the content path */ - #[ReadonlyModelProperty] protected ?string $relativePath = null; /** * Page route */ - #[ReadonlyModelProperty] protected ?string $route = null; /** @@ -117,13 +116,11 @@ class Page extends Model implements Stringable /** * Page content file */ - #[ReadonlyModelProperty] protected ?ContentFile $contentFile = null; /** * Page last modified time */ - #[ReadonlyModelProperty] protected int $lastModifiedTime; /** @@ -134,7 +131,6 @@ class Page extends Model implements Stringable /** * Available page languages */ - #[ReadonlyModelProperty] protected Languages $languages; /** @@ -150,7 +146,6 @@ class Page extends Model implements Stringable /** * Page files */ - #[ReadonlyModelProperty] protected FileCollection $files; /** @@ -161,12 +156,12 @@ class Page extends Model implements Stringable /** * Page loading state */ - #[ReadonlyModelProperty] protected bool $loaded = false; /** * Reference to the site */ + #[Setter] protected Site $site; /** @@ -202,6 +197,7 @@ public function __call(string $name, array $arguments): mixed /** * Return site */ + #[Getter] public function site(): Site { return $this->site ??= $this->app()->site(); @@ -402,6 +398,7 @@ public function reload(array $data = []): void /** * Get page path */ + #[Getter] public function path(): ?string { return $this->path; @@ -410,6 +407,7 @@ public function path(): ?string /** * Get page relative path */ + #[Getter] public function relativePath(): ?string { return $this->relativePath; @@ -434,6 +432,7 @@ public function contentRelativePath(): ?string /** * Get page route */ + #[Getter] public function route(): ?string { return $this->route; @@ -442,6 +441,7 @@ public function route(): ?string /** * Get the canonical page URI, or `null` if not available */ + #[Getter] public function canonicalRoute(): ?string { return empty($this->data['canonicalRoute']) @@ -452,6 +452,7 @@ public function canonicalRoute(): ?string /** * Get page slug */ + #[Getter] public function slug(): ?string { return $this->slug; @@ -460,6 +461,7 @@ public function slug(): ?string /** * Get page num */ + #[Getter] public function num(): ?int { if ($this->num !== null) { @@ -473,6 +475,7 @@ public function num(): ?int /** * Return page icon */ + #[Getter] public function icon(): string { return $this->data['icon'] ?? $this->scheme()->options()->get('icon', 'page'); @@ -481,6 +484,7 @@ public function icon(): string /** * Get page filename */ + #[Getter] public function contentFile(): ?ContentFile { return $this->contentFile; @@ -489,6 +493,7 @@ public function contentFile(): ?ContentFile /** * Get page template */ + #[Getter] public function template(): Template { return $this->template; @@ -497,6 +502,7 @@ public function template(): Template /** * Get page last modified time */ + #[Getter] public function lastModifiedTime(): ?int { if ($this->path === null) { @@ -513,6 +519,7 @@ public function lastModifiedTime(): ?int /** * Get page language */ + #[Getter] public function language(): ?Language { return $this->language; @@ -521,6 +528,7 @@ public function language(): ?Language /** * Get page languages */ + #[Getter] public function languages(): Languages { return $this->languages; @@ -529,6 +537,7 @@ public function languages(): Languages /** * Get page metadata */ + #[Getter] public function metadata(): MetadataCollection { if (isset($this->metadata)) { @@ -547,6 +556,7 @@ public function metadata(): MetadataCollection * * @since 2.2.0 */ + #[Getter] public function taxonomy(): array { return $this->data['taxonomy']; @@ -555,6 +565,7 @@ public function taxonomy(): array /** * Get page HTTP response status */ + #[Getter] public function responseStatus(): ResponseStatus { if (isset($this->responseStatus)) { @@ -578,6 +589,7 @@ public function responseStatus(): ResponseStatus /** * Get page files */ + #[Getter] public function files(): FileCollection { return $this->files; @@ -586,6 +598,7 @@ public function files(): FileCollection /** * Return all page images */ + #[Getter(export: false)] public function images(): FileCollection { return $this->files()->filterBy('type', 'image'); @@ -594,6 +607,7 @@ public function images(): FileCollection /** * Return all page videos */ + #[Getter(export: false)] public function videos(): FileCollection { return $this->files()->filterBy('type', 'video'); @@ -604,6 +618,7 @@ public function videos(): FileCollection * * @since 2.3.6 */ + #[Getter(export: false)] public function audios(): FileCollection { return $this->files()->filterBy('type', 'audio'); @@ -612,6 +627,7 @@ public function audios(): FileCollection /** * Return all page media files (images, videos, and audios) */ + #[Getter(export: false)] public function media(): FileCollection { return $this->files()->filterBy('type', fn(string $type) => in_array($type, ['image', 'video', 'audio'], true)); @@ -620,6 +636,7 @@ public function media(): FileCollection /** * Return whether the page has loaded */ + #[Getter(key: 'loaded', export: false)] public function hasLoaded(): bool { return $this->loaded; @@ -680,7 +697,10 @@ public function isErrorPage(): bool */ public function isIndexOrErrorPage(): bool { - return $this->isIndexPage() || $this->isErrorPage(); + if ($this->isIndexPage()) { + return true; + } + return $this->isErrorPage(); } /** @@ -990,6 +1010,7 @@ protected function getFrontmatterData(): array * * @throws UnexpectedValueException If site path is missing */ + #[Setter] protected function setPath(?string $path): void { if ($path === null) { @@ -1021,6 +1042,7 @@ protected function setPath(?string $path): void * * @throws InvalidValueException If the slug is invalid, for index or error pages, or if a page with the same route already exists */ + #[Setter] protected function setSlug(string $slug): void { if (!$this->validateSlug($slug)) { @@ -1043,6 +1065,7 @@ protected function setSlug(string $slug): void * * If no arguments are passed, the num is set based on the current mode */ + #[Setter] protected function setNum(?int $num = null): void { if (func_num_args() === 0) { @@ -1070,6 +1093,7 @@ protected function setNum(?int $num = null): void * * @throws InvalidValueException If the parent is invalid */ + #[Setter] protected function setParent(Page|Site|string $parent): void { $previousParent = $this->parent(); @@ -1090,6 +1114,7 @@ protected function setParent(Page|Site|string $parent): void * * @throws InvalidValueException If the template is invalid */ + #[Setter] protected function setTemplate(Template|string $template): void { $this->template = $this->resolveTemplate($template); @@ -1101,6 +1126,7 @@ protected function setTemplate(Template|string $template): void * * @throws InvalidValueException If the language is invalid */ + #[Setter] protected function setLanguage(Language|string|null $language): void { if ($language === null) { @@ -1131,6 +1157,7 @@ protected function setLanguage(Language|string|null $language): void * * @param array|MetadataCollection $metadata */ + #[Setter] protected function setMetadata(MetadataCollection|array $metadata): void { if ($metadata instanceof MetadataCollection) { @@ -1149,6 +1176,7 @@ protected function setMetadata(MetadataCollection|array $metadata): void * * @since 2.2.0 */ + #[Setter] protected function setTaxonomy(array $taxonomy): void { if (!Arr::every($taxonomy, fn($terms, $taxonomyName) => is_string($taxonomyName) @@ -1163,6 +1191,7 @@ protected function setTaxonomy(array $taxonomy): void * * @since 2.2.0 */ + #[Setter] protected function setResponseStatus(ResponseStatus|int|null $responseStatus): void { if ($responseStatus === null) { diff --git a/formwork/src/Pages/Traits/PageStatus.php b/formwork/src/Pages/Traits/PageStatus.php index 790b6f489..d6f8658c3 100644 --- a/formwork/src/Pages/Traits/PageStatus.php +++ b/formwork/src/Pages/Traits/PageStatus.php @@ -3,7 +3,7 @@ namespace Formwork\Pages\Traits; use Formwork\Cms\App; -use Formwork\Model\Attributes\ReadonlyModelProperty; +use Formwork\Data\Attributes\Getter; use Formwork\Pages\Page; use Formwork\Utils\Date; use UnexpectedValueException; @@ -13,12 +13,12 @@ trait PageStatus /** * Page status */ - #[ReadonlyModelProperty] protected string $status; /** * Get page status */ + #[Getter] public function status(): string { if (isset($this->status)) { @@ -28,7 +28,7 @@ public function status(): string /** * @var bool */ - $published = $this->get('published', true); + $published = $this->data['published'] ?? true; $now = time(); diff --git a/formwork/src/Pages/Traits/PageTraversal.php b/formwork/src/Pages/Traits/PageTraversal.php index 0a0079de6..8adf44d88 100644 --- a/formwork/src/Pages/Traits/PageTraversal.php +++ b/formwork/src/Pages/Traits/PageTraversal.php @@ -4,6 +4,7 @@ use Formwork\Cms\App; use Formwork\Cms\Site; +use Formwork\Data\Attributes\Getter; use Formwork\Pages\Page; use Formwork\Pages\PageCollection; use Formwork\Pages\PageCollectionFactory; @@ -65,6 +66,7 @@ abstract public function site(): Site; /** * Get parent page or site */ + #[Getter] public function parent(): Page|Site|null { if (isset($this->parent)) { @@ -103,6 +105,7 @@ public function isParentOf(Page|Site $page): bool /** * Return children pages */ + #[Getter] public function children(): PageCollection { if (isset($this->children)) { @@ -135,6 +138,7 @@ public function isChildOf(Page|Site $page): bool /** * Return descendant pages */ + #[Getter] public function descendants(): PageCollection { if (isset($this->descendants)) { @@ -167,6 +171,7 @@ public function isDescendantOf(Page|Site $page): bool /** * Return ancestor pages */ + #[Getter] public function ancestors(): PageCollection { if (isset($this->ancestors)) { @@ -204,6 +209,7 @@ public function isAncestorOf(Page|Site $page): bool /** * Return sibling pages */ + #[Getter] public function siblings(): PageCollection { return $this->siblings ?? ($this->siblings = $this->inclusiveSiblings()->without($this)); @@ -212,6 +218,7 @@ public function siblings(): PageCollection /** * Return a collection containing the page and its siblings */ + #[Getter] public function inclusiveSiblings(): PageCollection { if (isset($this->inclusiveSiblings)) { diff --git a/formwork/src/Pages/Traits/PageUid.php b/formwork/src/Pages/Traits/PageUid.php index a82dd99ef..57c582048 100644 --- a/formwork/src/Pages/Traits/PageUid.php +++ b/formwork/src/Pages/Traits/PageUid.php @@ -2,7 +2,7 @@ namespace Formwork\Pages\Traits; -use Formwork\Model\Attributes\ReadonlyModelProperty; +use Formwork\Data\Attributes\Getter; use Formwork\Utils\Str; trait PageUid @@ -10,7 +10,6 @@ trait PageUid /** * Page uid (unique identifier) */ - #[ReadonlyModelProperty] protected string $uid; /** @@ -21,6 +20,7 @@ abstract public function contentRelativePath(): ?string; /** * Get the page unique identifier */ + #[Getter] public function uid(): string { if (isset($this->uid)) { From 70fe0e498c708046f596d9d15a7abd54190425b7 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:25:42 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Deprecate=C2=A0`ReadonlyModelProperty`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- formwork/src/Model/Attributes/ReadonlyModelProperty.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/formwork/src/Model/Attributes/ReadonlyModelProperty.php b/formwork/src/Model/Attributes/ReadonlyModelProperty.php index 2ae48ba79..e366e0d6b 100644 --- a/formwork/src/Model/Attributes/ReadonlyModelProperty.php +++ b/formwork/src/Model/Attributes/ReadonlyModelProperty.php @@ -3,6 +3,12 @@ namespace Formwork\Model\Attributes; use Attribute; +use Formwork\Data\Attributes\Getter; +trigger_error(sprintf('%s is deprecated since Formwork 2.4.0. Use the new %s instead', ReadonlyModelProperty::class, Getter::class), E_USER_DEPRECATED); + +/** + * @deprecated since 2.4.0. Use `Formwork\Data\Attributes\Getter` instead + */ #[Attribute(Attribute::TARGET_PROPERTY)] class ReadonlyModelProperty {} From c5f2150a33c7663ca6690f5cf45ddf82d74d00db Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:28:45 +0200 Subject: [PATCH 3/5] Add defaults to `Field` --- formwork/src/Fields/Field.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/formwork/src/Fields/Field.php b/formwork/src/Fields/Field.php index f3ea3fb84..16e27c010 100644 --- a/formwork/src/Fields/Field.php +++ b/formwork/src/Fields/Field.php @@ -13,6 +13,7 @@ use Formwork\Fields\Exceptions\ValidationException; use Formwork\Fields\Translations\Translations; use Formwork\Traits\Methods; +use Formwork\Utils\Arr; use Formwork\Utils\Constraint; use Formwork\Utils\Str; use Stringable; @@ -60,7 +61,7 @@ public function __construct( array $data = [], protected ?FieldCollection $parentFieldCollection = null, ) { - $this->setMultiple($data); + $this->setMultiple(Arr::override($this->defaults(), $data)); if ($this->has('fields')) { throw new UnexpectedValueException('Fields may not have other fields inside'); @@ -358,4 +359,20 @@ protected function callMethod(string $method, array $arguments = []): mixed { return $this->methods[$method](...[$this, ...$arguments]); } + + /** + * Return default field data + * + * @return array + */ + protected function defaults(): array + { + return [ + 'formName' => Str::dotNotationToBrackets($this->name()), + 'required' => false, + 'disabled' => false, + 'visible' => true, + 'readonly' => false, + ]; + } } From f36c604926e0d28026b88d3a1fe88f3b7c0cf960 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:34:06 +0200 Subject: [PATCH 4/5] Review model accessors to ensure compatibility --- formwork/src/Cms/Site.php | 1 + formwork/src/Data/Traits/DataGetter.php | 2 +- formwork/src/Files/File.php | 10 ++++ formwork/src/Images/Image.php | 18 ++++-- formwork/src/Model/Model.php | 78 +++++++++++++++++-------- formwork/src/Pages/Page.php | 27 ++++++++- formwork/src/Users/User.php | 27 ++++----- 7 files changed, 115 insertions(+), 48 deletions(-) diff --git a/formwork/src/Cms/Site.php b/formwork/src/Cms/Site.php index 790144745..b1bbbc1d4 100644 --- a/formwork/src/Cms/Site.php +++ b/formwork/src/Cms/Site.php @@ -95,6 +95,7 @@ class Site extends Model implements Stringable * * @var array */ + #[Getter] protected array $storage = []; /** diff --git a/formwork/src/Data/Traits/DataGetter.php b/formwork/src/Data/Traits/DataGetter.php index 34d858d4b..2e4e9388d 100644 --- a/formwork/src/Data/Traits/DataGetter.php +++ b/formwork/src/Data/Traits/DataGetter.php @@ -42,7 +42,7 @@ public function get(string $key, mixed $default = null): mixed { if ($getter = $this->dataGetters()[$key] ?? null) { return match ($getter['type']) { - 'property' => $this->{$getter['name']}, + 'property' => $this->{$getter['name']} ?? $default, 'method' => $this->{$getter['name']}(), }; } diff --git a/formwork/src/Files/File.php b/formwork/src/Files/File.php index f5f0cf346..c033c5b6d 100644 --- a/formwork/src/Files/File.php +++ b/formwork/src/Files/File.php @@ -3,6 +3,7 @@ namespace Formwork\Files; use Formwork\Data\Attributes\Getter; +use Formwork\Data\Attributes\Setter; use Formwork\Data\Contracts\Arrayable; use Formwork\Files\Exceptions\FileUriGenerationException; use Formwork\Model\Model; @@ -213,6 +214,7 @@ public function contentHash(): string * * @internal */ + #[Setter] public function setUriGenerator(FileUriGenerator $uriGenerator): void { $this->uriGenerator = $uriGenerator; @@ -244,9 +246,17 @@ public function absoluteUri(): string return $this->uriGenerator->generateAbsolute($this); } + public function toArray(): array + { + // Use `convertToArray(false)` to avoid including all properties implicitly + // as before Formwork 2.4.0 + return $this->convertToArray(includeAllProperties: false); + } + /** * Set file scheme */ + #[Setter] public function setScheme(Scheme $scheme): void { $this->scheme = $scheme; diff --git a/formwork/src/Images/Image.php b/formwork/src/Images/Image.php index f47642fa2..c03c07947 100644 --- a/formwork/src/Images/Image.php +++ b/formwork/src/Images/Image.php @@ -63,7 +63,6 @@ class Image extends File */ public function __construct( string $path, - #[Getter(export: false)] protected array $options, ) { parent::__construct($path); @@ -302,7 +301,7 @@ public function hasColorProfile(): bool * * @throws UnsupportedFeatureException If the image does not support color profiles */ - #[Getter] + #[Getter(export: false)] public function getColorProfile(): ?ColorProfile { return $this->handler()->getColorProfile(); @@ -342,7 +341,7 @@ public function hasExifData(): bool * * @throws UnsupportedFeatureException If the image does not support EXIF data */ - #[Getter] + #[Getter(export: false)] public function getExifData(): ?ExifData { return $this->handler()->getExifData(); @@ -491,7 +490,7 @@ public function saveAs(string $path, ?string $mimeType = null): void } /** - * Get image info as an array + * Get image info */ #[Getter] public function info(): ImageInfo @@ -523,6 +522,17 @@ public function height(): int return $this->process()->info()->height(); } + public function toArray(): array + { + $data = [ + ...parent::toArray(), + 'imageInfo' => $this->info()->toArray(), // @todo Remove in Formwork 3.0.0 + 'uri' => $this->uri(), // @todo Remove in Formwork 3.0.0 + ]; + ksort($data); + return $data; + } + /** * Get image hash based on its path, transforms and format * diff --git a/formwork/src/Model/Model.php b/formwork/src/Model/Model.php index 369671f1d..25ac9b32e 100644 --- a/formwork/src/Model/Model.php +++ b/formwork/src/Model/Model.php @@ -16,6 +16,7 @@ use Formwork\Utils\Arr; use LogicException; use ReflectionAttribute; +use ReflectionClass; use ReflectionProperty; /** @@ -69,7 +70,7 @@ public function __call(string $name, array $arguments): mixed return $this->get($name); } - throw new LogicException(sprintf('Call to undefined method %s::%s()', static::class, $name)); + throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name)); } /** @@ -123,7 +124,7 @@ public function get(string $key, mixed $default = null): mixed { if ($getter = $this->dataGetters()[$key] ?? null) { return match ($getter['type']) { - 'property' => $this->{$getter['name']}, + 'property' => $this->{$getter['name']} ?? $default, 'method' => $this->{$getter['name']}(), }; } @@ -176,7 +177,7 @@ public function set(string $key, mixed $value): void } if (isset($this->dataGetters()[$key])) { - throw new LogicException(sprintf('Cannot set getter-only key %s', $key)); + throw new LogicException(sprintf('Cannot set getter-only key "%s"', $key)); } if (property_exists($this, $key) && !(new ReflectionProperty($this, $key))->isPromoted()) { @@ -217,50 +218,77 @@ public function set(string $key, mixed $value): void */ public function toArray(): array { - $data = []; + return $this->convertToArray(includeAllProperties: true); + } + + /** + * Return the model data + * + * @return array + */ + #[Getter(export: false)] + public function data(): array + { + return $this->data; + } + + /** + * Return the model data + * + * This is needed until Formwork 3.0.0 to bypass properties without accessors in models that were never exported (like File and Image) + * + * @internal + * + * @todo Remove in Formwork 3.0.0 as `toArray()` will be the same as calling `convertToArray(false)` + * + * @return array + */ + protected function convertToArray(bool $includeAllProperties = false): array + { + $data = $this->data; foreach ($this->dataGetters() as $key => $accessor) { - if ($accessor['export']) { - $data[$key] = match ($accessor['type']) { - 'method' => $this->{$accessor['name']}(), - 'property' => $this->{$accessor['name']}, - }; + if ($accessor['export'] === false) { + unset($data[$key]); + continue; } + + $data[$key] = match ($accessor['type']) { + 'method' => $this->{$accessor['name']}(), + 'property' => $this->{$accessor['name']}, + }; } - $properties = array_diff( - array_keys(get_class_vars(static::class)), - array_keys($this->dataGetters()), - ['data', 'dataAccessors'] - ); + $properties = $includeAllProperties + ? array_diff( + Arr::map( + Arr::reject((new ReflectionClass(static::class))->getProperties(), fn($p) => $p->isPromoted()), + fn($p) => $p->getName() + ), + array_keys($this->dataGetters()), + array_column($this->dataGetters(), 'name'), + ['data', 'dataAccessors'] + ) + : []; if (count($properties) > 0) { trigger_error(sprintf('Getting the following properties implicitly with the toArray() method is deprecated since Formwork 2.4.0: %s. Add the %s(export: true) attribute to the properties to explicitly allow this behavior', implode(', ', array_map(fn($property) => static::class . '::$' . $property, $properties)), Getter::class), E_USER_DEPRECATED); } /** @var list $properties */ - $data += [...$this->data, ...$this->getMultiple($properties)]; + $data += $this->getMultiple($properties); ksort($data); return $data; } - /** - * Return the model data - * - * @return array - */ - public function data(): array - { - return $this->data; - } - /** * Get the application instance * * @since 2.3.0 */ + #[Getter] protected function app(): App { return $this->app ?? App::instance(); diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index c079b6979..3aa25af92 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -161,7 +161,6 @@ class Page extends Model implements Stringable /** * Reference to the site */ - #[Setter] protected Site $site; /** @@ -203,6 +202,18 @@ public function site(): Site return $this->site ??= $this->app()->site(); } + /** + * Set site + */ + #[Setter] + public function setSite(Site $site): void + { + if (isset($this->site)) { + throw new RuntimeException('Site already set for the page'); + } + $this->site = $site; + } + /** * Return page default data * @@ -636,7 +647,7 @@ public function media(): FileCollection /** * Return whether the page has loaded */ - #[Getter(key: 'loaded', export: false)] + #[Getter(key: 'loaded')] public function hasLoaded(): bool { return $this->loaded; @@ -1037,6 +1048,18 @@ protected function setPath(?string $path): void $this->slug ??= basename($this->route); } + /** + * Set the canonical page URI + */ + #[Setter] + protected function setCanonicalRoute(?string $canonicalRoute): void + { + if ($canonicalRoute !== null) { + $canonicalRoute = Path::normalize($canonicalRoute); + } + $this->data['canonicalRoute'] = $canonicalRoute; + } + /** * Set page slug * diff --git a/formwork/src/Users/User.php b/formwork/src/Users/User.php index 2f7a95105..b4fed57b6 100644 --- a/formwork/src/Users/User.php +++ b/formwork/src/Users/User.php @@ -6,6 +6,7 @@ use Formwork\Authentication\Exceptions\AuthenticationFailedException; use Formwork\Authentication\Exceptions\UserNotLoggedException; use Formwork\Config\Config; +use Formwork\Data\Attributes\Getter; use Formwork\Data\Exceptions\InvalidValueException; use Formwork\Exceptions\TranslatedException; use Formwork\Files\FileFactory; @@ -219,20 +220,6 @@ public function canChangeRoleOf(User $user): bool return $this->isAdmin() && $this->username() !== $user->username(); } - /** - * Get data by key returning a default value if key is not present - * - * @throws LogicException If trying to access the user password hash - */ - public function get(string $key, mixed $default = null): mixed - { - if ($key === 'hash') { - throw new LogicException('Cannot access user password hash'); - } - - return parent::get($key, $default); - } - /** * Set a data value by key * @@ -315,6 +302,12 @@ public function deleteImage(): void $this->save(); } + #[Getter(export: false)] + protected function hash(): never + { + throw new LogicException('Cannot access user password hash'); + } + /** * Load user scheme and fields */ @@ -411,8 +404,10 @@ protected function getPasswordHash(): string * * @throws InvalidValueException If the password is too short */ - protected function setPasswordHash(string $password): void - { + protected function setPasswordHash( + #[SensitiveParameter] + string $password + ): void { if (strlen($password) < self::MINIMUM_PASSWORD_LENGTH) { throw new InvalidValueException(sprintf('Password must be at least %d characters long', self::MINIMUM_PASSWORD_LENGTH)); } From 1e8f174374074aa0a437733d24d095922812c284 Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:34:37 +0200 Subject: [PATCH 5/5] Decouple `AbstractCollection` from accessors --- formwork/src/Data/AbstractCollection.php | 26 ++++++++++-------------- formwork/src/Utils/Arr.php | 15 +++++++------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/formwork/src/Data/AbstractCollection.php b/formwork/src/Data/AbstractCollection.php index 46d1c3f61..6c4d0d89e 100644 --- a/formwork/src/Data/AbstractCollection.php +++ b/formwork/src/Data/AbstractCollection.php @@ -4,7 +4,6 @@ use Countable; use Formwork\Data\Contracts\Arrayable; -use Formwork\Data\Traits\DataArrayable; use Formwork\Data\Traits\DataCountableIterator; use Formwork\Data\Traits\DataMultipleGetter; use Formwork\Data\Traits\DataMultipleSetter; @@ -20,22 +19,14 @@ */ abstract class AbstractCollection implements Arrayable, Countable, Iterator { - use DataArrayable; - /** @use DataCountableIterator> */ use DataCountableIterator; /** @use DataMultipleGetter> */ - use DataMultipleGetter { - has as protected baseHas; - get as protected baseGet; - } + use DataMultipleGetter; /** @use DataMultipleSetter> */ - use DataMultipleSetter { - set as protected baseSet; - remove as protected baseRemove; - } + use DataMultipleSetter; /** * @var array @@ -718,7 +709,7 @@ public function has(string $key): bool if (!$this->isAssociative()) { throw new LogicException('Value presence can be checked only in associative collections'); } - return $this->baseHas($key); + return Arr::has($this->data, $key); } /** Get a collection item by the given key @@ -740,7 +731,7 @@ public function get(string $key, mixed $default = null): mixed if (!$this->isAssociative()) { throw new LogicException('Values can be get only from associative collections'); } - return $this->baseGet($key, $default); + return Arr::get($this->data, $key, $default); } /** @@ -766,7 +757,7 @@ public function set(string $key, mixed $value): void $this->data[$key] = null; } - $this->baseSet($key, $value); + Arr::set($this->data, $key, $value); } /** @@ -779,7 +770,7 @@ public function remove(string $key): void if (!$this->isAssociative() || !$this->isMutable()) { throw new LogicException('Values can be removed only from associative and mutable collections'); } - $this->baseRemove($key); + Arr::remove($this->data, $key); } /** @@ -805,4 +796,9 @@ public function merge(self $collection): void $this->data = [...$this->data, ...$collection->data]; } + + public function toArray(): array + { + return $this->data; + } } diff --git a/formwork/src/Utils/Arr.php b/formwork/src/Utils/Arr.php index 023210eff..12771a39f 100644 --- a/formwork/src/Utils/Arr.php +++ b/formwork/src/Utils/Arr.php @@ -17,11 +17,12 @@ final class Arr * using dot notation to traverse if literal key is not found * * @template TValue + * @template TDefault * - * @param array $array - * @param TValue|null $default + * @param array $array + * @param TDefault|TValue $default * - * @return TValue|null + * @return TDefault|TValue */ public static function get(array $array, string $key, mixed $default = null): mixed { @@ -43,7 +44,7 @@ public static function get(array $array, string $key, mixed $default = null): mi * * @template TValue * - * @param array $array + * @param array $array */ public static function has(array $array, string $key): bool { @@ -64,8 +65,8 @@ public static function has(array $array, string $key): bool * * @template TValue * - * @param array $array - * @param TValue $value + * @param array $array + * @param TValue $value */ public static function set(array &$array, string $key, mixed $value): void { @@ -87,7 +88,7 @@ public static function set(array &$array, string $key, mixed $value): void /** * Remove data by key using dot notation to traverse if literal key is not found * - * @param array $array + * @param array $array */ public static function remove(array &$array, string $key): void {