Collections & data
A collection is a user-defined data model. Its metadata lives in page_builder_models (the PbModel) and page_builder_fields (the PbFields), and the SchemaSynchronizer turns that metadata into a real database table named pb_<key> (Directus-style). Every collection automatically gets a REST API, and Flows, Functions, pages and the AI applier all read/write it through one service: RecordQuery.
Defining a collection
In the admin: Collections → create. A collection has:
| Field | Notes |
|---|---|
key |
Lowercase identifier ^[a-z][a-z0-9_]*$. Immutable after create. Drives the physical table name. |
name |
Human label |
label_singular / label_plural |
Optional display labels |
icon |
Heroicon |
has_timestamps |
Add created_at/updated_at (default true) |
has_soft_deletes |
Add deleted_at (default false) |
description |
Optional |
The physical table name is {data.table_prefix}{key} with dashes converted to underscores — default prefix pb_, so collection key leads → table pb_leads (PbModel::physicalTableName()). Deleting a collection in the admin drops its physical table (SchemaSynchronizer::dropTable()).
Field types
src/Enums/FieldType.php — the single source of truth mapping a logical type to its physical column, Eloquent cast and validation rule.
type |
Label | Physical column | Cast | Notes |
|---|---|---|---|---|
string |
Text (single line) | string(length) (default 255) |
— | options.length |
text |
Text (multi-line) | text |
— | |
integer |
Integer | bigInteger |
integer |
|
decimal |
Decimal | decimal(precision, scale) (default 12,2) |
float |
options.precision, options.scale |
boolean |
Boolean | boolean |
boolean |
|
date |
Date | date |
date |
|
datetime |
Date & time | dateTime |
datetime |
|
json |
JSON | json |
array |
|
select |
Select (choices) | string(length) |
— | options.choices (string list) |
relation |
Relation (belongs to) | unsignedBigInteger as {key}_id |
integer |
indexed; set options.relation_model to the related collection key |
Field options
A field's options (JSON) tune the column and validation:
required(bool) — non-nullable +requiredvalidation (otherwise the column is nullable and validation isnullable).nullable(bool) — explicit override of nullability.default— column default (cast to the field type).unique(bool) — unique index.index(bool) — plain index (relations are always indexed).length(int) — forstring/select.precision/scale(int) — fordecimal.choices(string[]) — forselect; adds anin:validation rule.relation_model(string) — forrelation; the related collection key.
Column naming: a
relationfield stores its foreign id as{key}_id; every other type uses the key verbatim. So in API payloads a relation field accepts either the field key or the_idcolumn name.
Schema sync
SchemaSynchronizer::sync(PbModel $model):
- Create — if the table doesn't exist, creates it with
id, each field's column, plustimestamps()/softDeletes()per the collection flags. - Alter — if it exists, adds columns for new fields, adds
created_at/updated_at/deleted_atif newly enabled, and (only when destructive sync is on) drops columns for removed fields.
Destructive sync is off by default. Removing a field does not drop its column unless data.allow_destructive_sync is true (AI_PAGE_BUILDER_DATA_DESTRUCTIVE=true) — a guard so a mis-edit can't destroy data. The system columns id, created_at, updated_at, deleted_at are never dropped.
The auto REST API
Every collection is exposed under {data.api_prefix}/{model} (default api/pb), resolved by collection key:
| Method | Path | Action |
|---|---|---|
GET |
/api/pb/{model} |
List (paginated) |
POST |
/api/pb/{model} |
Create |
GET |
/api/pb/{model}/{id} |
Read one |
PUT/PATCH |
/api/pb/{model}/{id} |
Update |
DELETE |
/api/pb/{model}/{id} |
Delete |
The API middleware (data.api_middleware) is EncryptCookies, StartSession, api — so the built app's logged-in end-user (the pb guard) is recognised on same-origin XHR and permission + row-level rules apply. No CSRF is added, so stateless writes to unrestricted collections keep working. Permission enforcement happens in RecordApiController via AccessControl — see Authentication & permissions.
Query syntax (RecordQuery)
Directus-style query parameters, parsed by src/Services/Data/RecordQuery.php.
Filter — filter[field][op]=value:
op |
Meaning |
|---|---|
eq |
= (also the shorthand: filter[status]=active) |
neq |
!= |
gt / gte |
> / >= |
lt / lte |
< / <= |
like |
LIKE '%value%' |
in / nin |
whereIn / whereNotIn (array or comma-separated) |
null / nnull |
whereNull / whereNotNull |
between |
whereBetween (first two values of an array) |
GET /api/pb/leads?filter[status][eq]=active
GET /api/pb/leads?filter[id][in]=1,2,3
GET /api/pb/leads?filter[name][like]=acme
GET /api/pb/leads?filter[created_at][gte]=2026-01-01Sort — sort=field,-other (comma-separated; leading - = descending; defaults to id ascending):
GET /api/pb/leads?sort=name,-created_atFields (projection) — fields=a,b,c (only defined/system columns; id always included):
GET /api/pb/leads?fields=name,email,statusSearch — search=term (LIKE across string/text/select columns):
GET /api/pb/leads?search=acmePagination — page (default 1) and per_page (clamped between 1 and data.max_per_page; default data.default_per_page = 25).
GET /api/pb/leads?page=2&per_page=50Response envelope
list() returns Laravel's paginator JSON:
{
"data": [ { "id": 1, "name": "Acme", "...": "..." } ],
"meta": { "current_page": 1, "from": 1, "last_page": 5, "per_page": 25, "to": 25, "total": 125 },
"links": { "first": "...", "last": "...", "next": "...", "prev": null }
}Single-record verbs return { "data": { ... } }. store returns 201; destroy returns 204. A failed validation returns 422 with Laravel's error bag; a denied permission returns 403.
Reading/writing from Flows, Functions and pages
- Flows — the
recordnode doeslist/get/create/update/deleteagainst a collection, going throughRecordQuery. - Functions — a
php-runtime Function can callRecordQuerydirectly (or use therecordnode). - Pages — the
data_tableblock /pbTablefetches the list endpoint; adata-pb-record="<collection>"form POSTs a create.
Every one of these paths funnels through RecordQuery, so validation, column mapping (field key ↔ _id), and the row-level permission filter are applied consistently.
Validation
RecordQuery::validate() builds rules from each field's FieldType::validationRules($options). Input may be keyed by field key or physical column name (relations accept manager or manager_id). Updates validate in partial mode (only present fields; required is relaxed). Unmapped keys are dropped.
Next: Flows.