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 `