diff --git a/CHANGELOG.md b/CHANGELOG.md index 7327881..bf3fe89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ apply pending schema migrations before doing their work. but the command opened a raw PDO without running pending migrations. The rebuild then fired against the pre-2.2.0 schema (`fields.searchable = 0` everywhere) and silently produced an - empty/incorrect FTS body for every item. Hit while bumping - Scriptor; fixed before reaching the Hetzner demo. (#60) + empty/incorrect FTS body for every item. Caught during a + downstream lock-bump, before the broken recipe reached + production. (#60) - **`optimize` now auto-migrates too.** Same surprise was latent: running `VACUUM` before migrations wastes cycles because the next command applies new tables/columns anyway. (#60) @@ -34,7 +35,7 @@ apply pending schema migrations before doing their work. now applies migration `0005` first, then rebuilds against the correct schema. - Diagnostic commands (`dump`, `repair`, `schema:status`) are - unchanged — they're expected to report on the actual on-disk + unchanged. They're expected to report on the actual on-disk state, so they must NOT auto-migrate. ## [2.2.0] — 2026-05-17 @@ -67,18 +68,19 @@ Design spec: [`docs/imanager-2.2-plan.md`](docs/imanager-2.2-plan.md). - **Migration `0005_searchable_defaults.sql`.** Promotes existing `text`/`longtext`/`editor`/`slug` field rows to `searchable = 1` on upgrade so installs keep their existing FTS coverage for - prose content. Verified against the live Scriptor schema: all 8 - text-typed fields (slug, parent, pagetype, menu_title, content, - template, role, email) promote; password + fileupload fields - stay at 0. (#58) -- **`FtsBody::compose()`** — single source of truth for the body + prose content. Verified against a representative production + schema: all text-typed field rows promote; `password` and + file-upload field rows stay at 0. (#58) +- **`FtsBody::compose()`**: single source of truth for the body column written into `items_fts`. Used by both the per-save writer and the bulk rebuilder so they cannot drift. (#58) - **`SqliteItemRepository` constructor accepts optional `?FieldRepository`** as the third argument. The 2-arg signature - from 2.0/2.1 keeps working — falls back to "index everything" + from 2.0/2.1 keeps working: it falls back to "index everything" with a one-time `E_USER_DEPRECATED` notice on first FTS write. The no-arg form will become an error in 3.0. (#58) +- **`docs/imanager-2.2-plan.md`**: design plan with the + recommended-lean decision log and open-questions register. (#56) ### Upgrading from 2.1.x @@ -135,36 +137,36 @@ keeps working. Design spec: [`docs/imanager-2.1-plan.md`](docs/imanager-2.1-plan ### Added -- **16 static factories on `Field`** for declarative schema setup — +- **16 static factories on `Field`** for declarative schema setup: `Field::text()`, `longText()`, `editor()`, `slug()`, `password()`, `integer()`, `decimal()`, `money()`, `checkbox()`, `dropdown()`, `datepicker()`, `hidden()`, `arrayList()`, `file()`, `image()`, `filePicker()`. Each returns a fresh (`id = null`) `Field` with the corresponding `FieldType` set and default flags. (#47) -- **Fluent setters on `Field` — general (6)**: `required(bool=true)`, +- **General fluent setters on `Field` (6)**: `required(bool=true)`, `indexed(bool=true)`, `searchable(bool=true)`, `position(int)`, `label(string)`, `config(array)`. Each returns a new `final readonly` clone, preserving the value-object semantics. (#47) -- **Fluent setters on `Field` — type-aware (7)**: `maxLength(int)`, +- **Type-aware fluent setters on `Field` (7)**: `maxLength(int)`, `minLength(int)`, `placeholder(string)`, `maxBytes(int)`, `mimes(string ...)`, `options(array)`, `format(string)`. Each writes one documented key into `config`; unrecognised keys are silently ignored by built-in plugins, so a setter that doesn't apply to a given `FieldType` is a no-op. (#47) -- **`CategoryRepository::ensure(Category): Category`** — upsert by +- **`CategoryRepository::ensure(Category): Category`**. Upsert by natural key (`slug`). Insert-on-miss, return-existing-on-hit. Emits `CategoryCreated` only on insert. (#48) -- **`FieldRepository::ensure(Field): Field`** — upsert by natural key +- **`FieldRepository::ensure(Field): Field`**. Upsert by natural key `(categoryId, name)`. Same semantics as `CategoryRepository::ensure()`. Emits `FieldCreated` only on insert. (#48) -- **`docs/imanager-2.1-plan.md`** — design plan with the full surface - + naming rationale + open-questions log. (#46) +- **`docs/imanager-2.1-plan.md`**: design plan with the full surface, + naming rationale, and open-questions log. (#46) ### Fixed - **`Imanager::VERSION` is now bumped in lockstep with the git tag.** Previously the constant was set to `2.0.0` at 2.0 release and never - moved at 2.0.1 or 2.0.2 — `vendor/bin/imanager --version` reported + moved at 2.0.1 or 2.0.2, so `vendor/bin/imanager --version` reported the wrong value against any newer install. New `ReleaseConsistencyTest` asserts the constant matches the top-most `[X.Y.Z]` entry in CHANGELOG.md so this can't silently rot again. @@ -174,8 +176,8 @@ keeps working. Design spec: [`docs/imanager-2.1-plan.md`](docs/imanager-2.1-plan `ensure()` is a new interface method on `CategoryRepository` and `FieldRepository`. Direct callers of the existing methods need no changes. **Third-party implementers** of these interfaces (no known -implementers in the wild as of this release) need to add `ensure()` -— the canonical 4-line implementation is documented in the JSDoc +implementers in the wild as of this release) need to add `ensure()`. +The canonical 4-line implementation is documented in the JSDoc of each interface method. ## [2.0.2] — 2026-05-16 @@ -188,7 +190,7 @@ of each interface method. copy-paste of the README quickstart no longer trips on PDO's `unable to open database file` error. Hosts that hand-wire via `Imanager\Bootstrap::boot()` keep full control of directory - lifecycle — the convenience is specific to the copy-paste factory. + lifecycle; the auto-`mkdir` is specific to the copy-paste factory. (#40) ### Fixed @@ -197,7 +199,7 @@ of each interface method. parent directory** and suggests `mkdir -p ` (or bootstrapping via `DefaultBootstrap`, which now does that itself) when the SQLite open fails because the parent doesn't exist. The previous - error surfaced as the raw PDO "unable to open database file" — + error surfaced as the raw PDO "unable to open database file", opaque even to seasoned PHP devs. (#40) ### Changed diff --git a/README.md b/README.md index b975999..abde3e2 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ iManager is a small CMS **framework**, not a CMS application: you embed it inside your own PHP app and get a typed domain model, a Repository layer over SQLite (JSON columns + FTS5), a Field-Type plugin system, file storage with on-demand thumbnails, and a CLI for schema and -migration ops. Use it under any PHP front-end you like — a hand-rolled +migration ops. Use it under any PHP front-end you like: a hand-rolled admin tool, a flat-file CMS, an internal API, a static-site generator that needs a typed content store. iManager has no opinion about how your application is shaped. @@ -17,7 +17,7 @@ your application is shaped. ## Status -**Stable** — current line **2.0.x** (latest: 2.0.2, 2026-05-16). +**Stable**. Current line **2.2.x** (latest: 2.2.1, 2026-05-17). The 1.x line (flat-file, `var_export`-based, embedded library shape) stays available for legacy installs; see the @@ -59,20 +59,18 @@ $categories = $container->get(CategoryRepository::class); $fields = $container->get(FieldRepository::class); $items = $container->get(ItemRepository::class); -// One-time schema setup — run from an installer / migration, not on every -// request. Repeating these three calls against the same database raises a -// UNIQUE-constraint error; guard them with findBySlug() / findByName() if -// you want them to be idempotent. -$blog = $categories->save(new Category(null, 'Blog', 'blog')); -$fields->save(new Field(null, $blog->id, 'title', 'Title', FieldType::Text)); -$fields->save(new Field(null, $blog->id, 'body', 'Body', FieldType::LongText)); +// Schema setup: ensure() is idempotent (insert-on-miss, return-on-hit), +// so this block is safe to run on every boot. +$blog = $categories->ensure(new Category(null, 'Blog', 'blog')); +$fields->ensure(new Field(null, $blog->id, 'title', 'Title', FieldType::Text)); +$fields->ensure(new Field(null, $blog->id, 'body', 'Body', FieldType::LongText)); // Persist an item. $items->save(new Item( null, $blog->id, - 'hello-world', // name — URL-friendly identifier - 'Hello, world', // label — human-readable title + 'hello-world', // name: URL-friendly identifier + 'Hello, world', // label: human-readable title data: ['title' => 'Hello, world', 'body' => 'First post.'], )); @@ -91,7 +89,7 @@ pick up new migrations the same way. Need a leaner container or want to swap PDO / FileStorage / the event dispatcher? Use `Imanager\Bootstrap::boot()` instead and wire -the parts you want — `DefaultBootstrap` is just a copy-paste-saver +the parts you want. `DefaultBootstrap` is just a copy-paste-saver on top of it. --- @@ -100,17 +98,17 @@ on top of it. iManager models content as four primitives: -- **Category** — a kind of thing (e.g. *Blog*, *Page*, *User*). Each +- **Category**: a kind of thing (e.g. *Blog*, *Page*, *User*). Each category has its own field schema and its own slug. -- **Field** — a typed column on a category. The built-in field types +- **Field**: a typed column on a category. The built-in field types are: `text`, `longtext`, `editor`, `slug`, `password`, `integer`, `decimal`, `money`, `checkbox`, `dropdown`, `datepicker`, `hidden`, `array`, `fileupload`, `imageupload`, `filepicker`. Custom types register via the `FieldTypePlugin` interface. -- **Item** — an instance of a category. Field values live in a typed +- **Item**: an instance of a category. Field values live in a typed `FieldValueBag` exposed as `$item->data`; hot fields are also promoted to SQLite generated columns for indexable queries. -- **File** — a binary asset (upload). Files are stored under +- **File**: a binary asset (upload). Files are stored under `///` (the `uploadsPath` you pass to `DefaultBootstrap::boot()`), with on-demand thumbnails for image uploads under `thumbnail/x_`. @@ -134,7 +132,7 @@ container (`docker compose run --rm imanager vendor/bin/imanager …`). | `schema:migrate` | Apply pending migrations. | | `migrate:from-v1`| One-shot import of a 1.x `data/datasets/buffers/` tree. Supports `--dry-run`. | | `fts:rebuild` | Drop & rebuild the FTS5 index from `items`. | -| `optimize` | `PRAGMA optimize` + `VACUUM`. | +| `optimize` | `PRAGMA optimize`. Add `--vacuum` to also run `VACUUM`. | | `repair` | Integrity checks (orphan items, broken FKs, FTS sync). | | `dump` | Portable SQL dump. | @@ -175,26 +173,23 @@ Available composer scripts: ## Docs -- **Tutorial**: [`docs/tutorial/`](docs/tutorial/) — task-oriented +- **Tutorial**: [`docs/tutorial/`](docs/tutorial/), task-oriented walkthroughs for newcomers (setup, schema design, validation, …). Start here if you just installed the package and want a guided path past the Quickstart. -- **API reference**: [`docs/api/`](docs/api/) — index plus core +- **API reference**: [`docs/api/`](docs/api/), index plus core detail pages for Domain, Storage, Query, and Field types. -- **Field-types cookbook**: [`docs/field-types.md`](docs/field-types.md) - — how-to companion to the Field-types reference. -- **Query cookbook**: [`docs/query-cookbook.md`](docs/query-cookbook.md) - — predicate recipes, pagination, selector strings, full-text-search +- **Field-types cookbook**: [`docs/field-types.md`](docs/field-types.md), + how-to companion to the Field-types reference. +- **Query cookbook**: [`docs/query-cookbook.md`](docs/query-cookbook.md), + predicate recipes, pagination, selector strings, full-text-search hand-off, performance. -- **Deployment guide**: [`docs/deployment.md`](docs/deployment.md) - — host requirements, webserver + PHP-FPM configs, a production +- **Deployment guide**: [`docs/deployment.md`](docs/deployment.md), + host requirements, webserver + PHP-FPM configs, a production Dockerfile, SQLite at runtime, backups, scheduled maintenance. - **Migration guide** (1.x → 2.0): [`docs/migration-guide.md`](docs/migration-guide.md). - **Changelog**: [`CHANGELOG.md`](CHANGELOG.md). -- **Implementation history** — the multi-phase 1.x → 2.0 rewrite plan, - kept for context now that the work is done: - [`docs/imanager-2.0-plan.md`](docs/imanager-2.0-plan.md). --- diff --git a/docs/api/README.md b/docs/api/README.md index 7e56fc1..920e1ce 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,6 +1,6 @@ # API Reference -Reference documentation for the iManager 2.0 library — every public class +Reference documentation for the iManager 2 library. Every public class you can wire, extend, or call from a host application. > **Where to start:** the [README quickstart](../../README.md#quickstart) @@ -14,22 +14,22 @@ you can wire, extend, or call from a host application. ## Core (covered in detail here) The four pages below cover ~90 % of what host code touches. Read them -in order if you're new — each one builds on the previous. +in order if you're new. Each one builds on the previous. -- **[Domain](domain.md)** — `Category`, `Field`, `Item`, `File`, +- **[Domain](domain.md)**: `Category`, `Field`, `Item`, `File`, `FieldValueBag`, and the nine domain events (`*Created`, `*Updated`, `*Deleted`). All domain objects are `final readonly`; you mutate by saving a new value. -- **[Storage](storage.md)** — the `Storage` interface and its four +- **[Storage](storage.md)**: the `Storage` interface and its four repositories (`CategoryRepository`, `FieldRepository`, `ItemRepository`, `FileRepository`), `SqliteStorage` (the only bundled implementation), and the `transactional()` boundary. Also covers `SchemaManager` and `Migration`. -- **[Query](query.md)** — the immutable `Query` builder, the +- **[Query](query.md)**: the immutable `Query` builder, the `Clause` / `Operator` / `OrderBy` / `Direction` value objects, `Pagination`, and the `SelectorParser` shorthand (`name=Hello*, position>=5`). -- **[Field types](field-types.md)** — the `FieldTypePlugin` +- **[Field types](field-types.md)**: the `FieldTypePlugin` interface, `FieldTypeRegistry`, the 16 built-in plugins, and the `ValidationResult` / `RenderContext` value objects you return / receive when writing your own type. @@ -63,16 +63,16 @@ tells you which source file to read next. ## Conventions used in this reference -- **`final readonly`** — every domain primitive and most value objects +- **`final readonly`**: every domain primitive and most value objects are immutable. Operations like `withId()`, `withTitle()`, or the builder methods on `Query` return a new instance. -- **Method signatures** are quoted **verbatim** from the source — same +- **Method signatures** are quoted **verbatim** from the source: same parameter names, defaults, and return types. If something looks surprising, check the file path at the top of each page; the source wins, this doc follows. - **Examples** are lifted from the contract tests under `tests/Unit/Storage/*Contract.php`. Those tests run against both - `SqliteStorage` and the `InMemory` storage shipped for testing — so + `SqliteStorage` and the `InMemory` storage shipped for testing, so every example you see compiles and is exercised on every CI run. - **Exceptions thrown** are listed under each method that can throw. Methods without a "Throws" line never throw in normal operation @@ -85,16 +85,16 @@ tells you which source file to read next. The four core pages cover the public API any application boots against. Companion guides: -- [`docs/field-types.md`](../field-types.md) — **cookbook** for +- [`docs/field-types.md`](../field-types.md), the **cookbook** for writing custom field types end-to-end (anatomy, validation patterns, rendering patterns, registration, testing). The reference page [`field-types.md`](field-types.md) is the matching index. -- [`docs/query-cookbook.md`](../query-cookbook.md) — **cookbook** +- [`docs/query-cookbook.md`](../query-cookbook.md), the **cookbook** for the `Query` builder: predicate recipes, pagination flows, selector strings, full-text-search hand-off, performance. Matching reference page: [`query.md`](query.md). -- [`docs/deployment.md`](../deployment.md) — **deployment guide**: +- [`docs/deployment.md`](../deployment.md), the **deployment guide**: host requirements, filesystem layout, Caddy / nginx + PHP-FPM configs, a production Dockerfile, SQLite at runtime (WAL files, backups), scheduled maintenance, logging hookup, production @@ -102,5 +102,5 @@ against. Companion guides: The smaller subsystems (Cache, Templating, Http, Events, Validation) will get their own reference pages **only if** non-trivial host -extension is expected — for now their source files are short and +extension is expected. For now their source files are short and documented inline. diff --git a/docs/api/domain.md b/docs/api/domain.md index 2efe124..5165f53 100644 --- a/docs/api/domain.md +++ b/docs/api/domain.md @@ -12,7 +12,7 @@ happens at the repository layer; the domain itself is just data. ## Category -A kind of thing — `Blog`, `Page`, `User`. Each category owns its own +A kind of thing: `Blog`, `Page`, `User`. Each category owns its own field schema and its own slug. Categories are uniquely identified by both `name` and `slug` within an install (the storage layer rejects duplicates on either column). @@ -37,10 +37,10 @@ final readonly class Category ### Lifecycle -- `id === null` — a fresh value object that has not been persisted. +- `id === null`: a fresh value object that has not been persisted. Pass to `CategoryRepository::save()` to get back a clone with `id` assigned and `created`/`updated` populated. -- `id !== null` — an already-persisted record. Saving again **updates** +- `id !== null`: an already-persisted record. Saving again **updates** that row; the repository throws `NotFoundException` if the row no longer exists. @@ -57,7 +57,7 @@ $blog = $categories->save(new Category(null, 'Blog', 'blog')); A typed column on a category. Every item in the category may carry a value for every defined field. Fields are uniquely identified by -`(categoryId, name)` — the same `name` may exist in different +`(categoryId, name)`. The same `name` may exist in different categories. ```php @@ -95,13 +95,14 @@ final readonly class Field ### `config` An untyped `array` whose shape is defined per -field-type plugin (see [Field types](field-types.md)). Examples: +field-type plugin (see [Field types](field-types.md) for the +full key map per built-in). Examples: -- `TextFieldType`: `['max' => 255]` +- `TextFieldType`: `['maxLength' => 255, 'minLength' => 2]` - `DropdownFieldType`: `['options' => ['a' => 'Apple', 'b' => 'Banana']]` -- `ImageuploadFieldType`: `['maxBytes' => 5_000_000, 'mimes' => ['image/jpeg', 'image/png']]` +- `ImageuploadFieldType`: `['acceptedExtensions' => 'jpe?g|png', 'maxSizeBytes' => 5_000_000]` -The repository round-trips `config` verbatim — it's the plugin's job +The repository round-trips `config` verbatim: it's the plugin's job to make sense of its own shape. ### Example @@ -120,7 +121,7 @@ An instance of a category. Field values live in a typed `FieldValueBag` exposed on `$item->data`. The bag is the *whole* payload; "hot" fields (those declared `indexed` on the schema) are copied into SQLite generated columns on save and kept in sync -automatically — you don't write to them separately. +automatically, you don't write to them separately. ```php namespace Imanager\Domain; @@ -150,7 +151,7 @@ final readonly class Item The constructor accepts either an `array` or a `FieldValueBag` for ergonomics: most callers build items from arrays (`data: ['title' => 'Hello']`). Internally it's always coerced to a -`FieldValueBag` — that's what you read back from `$item->data`. +`FieldValueBag`, which is what you read back from `$item->data`. ### Example @@ -192,7 +193,7 @@ final readonly class FieldValueBag ``` The bag does **not** validate values against the field schema, and -neither does `ItemRepository::save()` — the repository writes +neither does `ItemRepository::save()`: the repository writes `Item::$data` verbatim. Validation is the host's responsibility: call `FieldTypeRegistry::get($field->type)->validate(...)` before constructing the `Item` you pass to `save()`. The @@ -261,7 +262,7 @@ Convenience that checks the MIME prefix; thumbnail generation in Every successful repository mutation publishes one of nine events through the PSR-14 dispatcher wired in `DefaultBootstrap`. Subscribers -receive the event *after* the SQLite transaction has committed — a +receive the event *after* the SQLite transaction has committed. A listener that throws does **not** roll back the write. All events implement the marker interface `DomainEvent`: @@ -330,8 +331,8 @@ final readonly class ItemUpdated implements DomainEvent ### Deleted events -Deleted events only carry the *id* (and category id for fields/items) -— by the time the event fires, the row is gone: +Deleted events only carry the *id* (and category id for fields/items). +By the time the event fires, the row is gone: ```php final readonly class CategoryDeleted implements DomainEvent @@ -360,7 +361,7 @@ final readonly class ItemDeleted implements DomainEvent ``` `FileCreated` / `FileUpdated` / `FileDeleted` are deliberately -**not** emitted today — file metadata moves with item state and the +**not** emitted today: file metadata moves with item state and the repository contract for files is intentionally narrower. ### Subscribing @@ -379,7 +380,7 @@ $provider->subscribe(ItemDeleted::class, function (ItemDeleted $event): void { }); ``` -Listener instantiation can be lazy — wrap the closure body in a +Listener instantiation can be lazy, wrap the closure body in a `static $listener = null` guard if construction is expensive (e.g. the listener queries the DB on construct to learn which category id it watches). diff --git a/docs/api/field-types.md b/docs/api/field-types.md index 8444eb7..57dc5ba 100644 --- a/docs/api/field-types.md +++ b/docs/api/field-types.md @@ -6,7 +6,7 @@ Field types are the plugin layer that decides: row) is **coerced and validated** into a domain value. 2. What **SQLite affinity** the value uses if it's promoted to a hot column. -3. How the value is **rendered** as an HTML form input — for hosts +3. How the value is **rendered** as an HTML form input, for hosts that lean on the library's rendering for their editor UI. This page is the **reference**: enum cases, the plugin interface, @@ -23,7 +23,7 @@ how-to for writing your own type lives in the ## `FieldType` enum The 16 case names every install knows out of the box. The `value` of -each case is also the string used in storage and in the registry — a +each case is also the string used in storage and in the registry. A field's `type` is stored as this string in the `fields` table. ```php @@ -54,7 +54,7 @@ enum FieldType: string `sqliteAffinity()` returns one of `Text` / `Integer` / `Real` / `Blob`. The result is consumed by the schema generator when a -field is `indexed = true` — the generated column's type is derived +field is `indexed = true`. The generated column's type is derived from this value. --- @@ -86,28 +86,49 @@ interface FieldTypePlugin ### `name()` / `affinity()` Static because the registry needs to resolve them without -instantiating the plugin. The `name()` is what the registry keys on -— it **must** equal the `FieldType` enum value for built-ins, and +instantiating the plugin. The `name()` is what the registry keys on: +it **must** equal the `FieldType` enum value for built-ins, and should be a unique slug-like string for custom plugins. The `affinity()` mirrors `FieldType::sqliteAffinity()` for the same -reason — schema codegen needs it before any instance exists. +reason: schema codegen needs it before any instance exists. ### `defaultConfig()` Returns the field's `config` array when a host (typically an editor -UI) creates a new field of this type. The shape is plugin-specific — -the library never inspects it. Common patterns: +UI) creates a new field of this type. The shape is plugin-specific. +The library never inspects it. The exact keys each built-in plugin +ships, with their defaults: | Plugin | `defaultConfig()` keys | |---|---| -| `TextFieldType` | `min`, `max`, `pattern` | -| `LongTextFieldType` | `max`, `format` (`'plain'` / `'markdown'`) | -| `DropdownFieldType` | `options` (`array`), `multiple` (`bool`) | -| `IntegerFieldType` | `min`, `max` | -| `MoneyFieldType` | `currency`, `precision` | -| `ImageuploadFieldType` | `maxBytes`, `mimes`, `maxWidth`, `maxHeight` | -| `FileuploadFieldType` | `maxBytes`, `mimes`, `multiple` | -| `FilepickerFieldType` | `categoryId` (target items), `searchable` | +| `TextFieldType` | `maxLength` (255), `minLength` (0), `placeholder` (`''`) | +| `LongTextFieldType` | `maxLength` (65535), `minLength` (0), `rows` (6), `placeholder` (`''`) | +| `EditorFieldType` | `mode` (`'markdown'`), `maxLength` (65535), `rows` (12) | +| `SlugFieldType` | `maxLength` (128) | +| `DatepickerFieldType` | `min` (`null`), `max` (`null`) | +| `DropdownFieldType` | `options` (`array`) | +| `CheckboxFieldType` | `label` (`''`) | +| `IntegerFieldType` | `min` (`null`), `max` (`null`), `step` (1) | +| `DecimalFieldType` | `min` (`null`), `max` (`null`), `precision` (2) | +| `MoneyFieldType` | `currency` (`'EUR'`), `min` (`null`), `max` (`null`), `precision` (2) | +| `PasswordFieldType` | `minLength` (8), `placeholder` (`'(unchanged)'`) | +| `HiddenFieldType` | `maxLength` (1024) | +| `ArrayListFieldType` | `maxItems` (100), `itemMaxLength` (255), `rows` (6) | +| `FilepickerFieldType` | `acceptedExtensions` (`'gif\|jpe?g\|png\|pdf'`) | +| `FileuploadFieldType` | `acceptedExtensions` (`'gif\|jpe?g\|png\|pdf\|zip'`), `maxFiles` (10), `maxSizeBytes` (10 MiB) | +| `ImageuploadFieldType` | `acceptedExtensions` (`'gif\|jpe?g\|png\|webp'`), `maxFiles` (10), `maxSizeBytes` (8 MiB), `thumbWidth` (150), `thumbHeight` (0) | + +> **Fluent setters vs runtime keys.** The 2.1.0 fluent setters +> (`->maxBytes()`, `->mimes()`, `->format()`) write into config under +> their *setter-name* key (`'maxBytes'`, `'mimes'`, `'format'`), which +> the upload-typed plugins do **not** read at runtime: they read +> `'maxSizeBytes'` and `'acceptedExtensions'`. The setter-name keys +> persist as documentation alongside the schema, and runtime +> enforcement happens through `UploadConstraints` rather than the +> plugin's config (see the +> [Files tutorial](../tutorial/files.md#constraints-in-two-places-read-this-twice)). +> If you're setting upload constraints in code and want them honored +> by the runtime, build `UploadConstraints` explicitly. ### `validate()` @@ -118,16 +139,16 @@ the canonical domain form, **or refuse** with a specific error code: public function validate(mixed $rawValue, Field $field): ValidationResult; ``` -`$rawValue` is whatever the caller passed — a string, an int, an +`$rawValue` is whatever the caller passed: a string, an int, an array, `null`, `false`. The plugin decides what it accepts. Return `ValidationResult::ok($coerced)` with the canonical value, or `ValidationResult::failed(InputErrorCode::*, $message)` to refuse. -`validate()` is invoked by **host code** — typically the editor +`validate()` is invoked by **host code**: typically the editor controller that receives the form submission. `ItemRepository::save()` itself writes `$item->data` verbatim and does **not** route values through the registry. The canonical pattern is "validate, then -save" — the +save". The [Field-types cookbook](../field-types.md#5-registering-a-custom-plugin) walks through the call shape. @@ -139,7 +160,7 @@ that round-trips through `validate()`". Use `RenderContext::$inputName` as the form-field name; the snippet must be safe to drop inside any parent `
`. -`render()` is purely an *editor* concern — your application's read +`render()` is purely an *editor* concern: your application's read templates touch `$item->data->get($name)` directly and do their own rendering. If you don't host an editor UI, `render()` can return an empty string. @@ -180,7 +201,7 @@ $registry = $container->get(FieldTypeRegistry::class); $registry->register(new MyColourPickerFieldType()); ``` -A custom type appears under whatever `name()` returns — pick +A custom type appears under whatever `name()` returns: pick something that won't collide with the 16 built-ins. The registry has no namespacing. @@ -212,7 +233,7 @@ named constructors' use, not for callers. ### `InputErrorCode` -The error vocabulary every plugin shares — `ItemRepository::save()` +The error vocabulary every plugin shares: `ItemRepository::save()` propagates the code through `ValidationException`, and editor UIs can map codes to localised messages without parsing free-text. The enum lives at `src/Enum/InputErrorCode.php`; the numeric values are @@ -233,21 +254,21 @@ enum InputErrorCode: int } ``` -- `EmptyRequired` — empty value on a `required` field. Checked first. -- `MinLengthExceeded` / `MaxLengthExceeded` — string-length bounds +- `EmptyRequired`: empty value on a `required` field. Checked first. +- `MinLengthExceeded` / `MaxLengthExceeded`: string-length bounds (`config.minLength` / `config.maxLength`) violated. -- `WrongValueFormat` — value's shape is wrong: dropdown key not in +- `WrongValueFormat`: value's shape is wrong: dropdown key not in `options`, integer field got a non-numeric string, date string doesn't parse. Catch-all for "the input doesn't make sense for this type". -- `ComparisonFailed` — cross-value or range comparison failed +- `ComparisonFailed`: cross-value or range comparison failed (numeric `min`/`max`, password-confirmation mismatch, date ordering). -- `UndefinedCategoryId` — reference-typed field (`filepicker`) +- `UndefinedCategoryId`: reference-typed field (`filepicker`) points at a category that doesn't exist. If none of these fit your plugin's failure mode, collapse the case -into `WrongValueFormat` rather than inventing new ones — the shared +into `WrongValueFormat` rather than inventing new ones. The shared vocabulary is what lets host editors map codes to localised messages without parsing free-text. @@ -269,13 +290,13 @@ final readonly class RenderContext } ``` -- `$inputName` — what the form-field's `name=""` attribute must be. +- `$inputName`: what the form-field's `name=""` attribute must be. The hosting editor decides this (often `"data[]"`). -- `$itemId` — `null` for "create" forms, the item id for "edit" +- `$itemId`: `null` for "create" forms, the item id for "edit" forms. Useful for file-typed plugins that need to render an existing-attachment list. -The context is deliberately tiny — anything more (CSRF token, asset +The context is deliberately tiny: anything more (CSRF token, asset base URL, current locale) is the *host's* concern. A plugin that needs more context should accept it via constructor injection when the host registers it. @@ -293,7 +314,7 @@ what SQLite type the generated column gets if you flip |---|---|---|---| | `Text` | `TextFieldType` | `Text` | Single-line ``. `config.min`, `config.max`, `config.pattern`. | | `LongText` | `LongTextFieldType` | `Text` | `