Data Objects
Record is the abstract base for Page, User, and Field. Understanding the hierarchy clarifies how field access, saving, and collection queries work throughout the framework.
Record
Record (Phlat\Data\Record) wraps an index.json file plus a colocated data.json, and exposes content fields through __get. It is the abstract base for Page, User, and Field. All reads and writes go through it.
Properties
| Property | Type | Description |
|---|---|---|
$path |
string |
Absolute filesystem path to the folder (no trailing slash) |
$uri |
string |
App-relative URI of the folder |
$uuid |
string |
Persisted identity, read from index.json |
$hidden |
bool |
Structural flag |
$status |
string |
Structural flag, 'published' by default |
$locked |
bool |
Structural flag |
$created / $updated |
?string |
ISO 8601 timestamps |
Methods
| Method | Returns | Description |
|---|---|---|
exists() |
bool |
True if index.json exists on disk |
path() |
string |
Same folder path, with a trailing slash |
name() |
string |
The folder name, last path segment |
folder() |
string |
App-relative folder URI |
save(array $data) |
void |
Splits $data by structuralKeys(): those keys to index.json, the rest to data.json |
Field access via __get
Accessing a key not declared as a typed PHP property triggers __get, which reads the value from data.json and passes it through the field() hook, returning a FieldValue.
$raw = $page->data()->get('body'); // string from data.json
$field = $page->body; // FieldValue via __get
echo $field; // FieldValue::__toString() -> decoded string
__call is also defined: $page->hero(...) resolves the hero field via __get, then invokes it if it's callable, this is how {$page->hero('w=400')} works as shorthand for src(...).
Page
Page extends Record and represents a single content page in the site tree. Always constructed via $app->page($uri), never directly.
Structural properties
Inherited from Record, populated from index.json. Page adds one of its own:
| Property | Type | Default |
|---|---|---|
$uuid |
string |
Minted at creation |
$view |
?string |
null |
$hidden |
bool |
false |
$status |
string |
'published' |
$locked |
bool |
false |
$created / $updated |
?string |
ISO 8601 timestamp |
title is not a structural property, it's an ordinary content field read from data.json like any other.
Methods
| Method | Returns | Description |
|---|---|---|
url(string $path = '', array $query = []) |
string |
The public URL of the page, e.g. /blog/my-post/ |
parent() |
?Page |
Parent page, or null at the root |
parents() |
PageCollection |
All ancestors, root-first |
child(string $slug) |
?Page |
A direct child page by slug, doesn't check it exists |
children(string $query = '') |
PageCollection |
Direct children (hidden excluded by default), filtered/sorted by $query |
find(string $query = '') |
PageCollection |
Recursive query across this page's subtree |
files() |
array |
PageFile objects for non-JSON files in the page folder |
view() |
PageView |
The resolved view for this page |
render(array $data = [], ?string $block = null) |
string |
Render the page to HTML, running controllers |
save(array $data) |
void |
Persists content to data.json; structural keys to index.json |
Fetching pages
$post = $app->page('blog/my-post'); // by path
$root = $app->page('/'); // root page
$current = $app->page(); // current request page
Content fields vs structural properties
$page->title; // FieldValue, from __get, a content field like any other
$page->view; // string|null, typed readonly property
Field
Field extends Record too, but it's a different thing from a page: a named, reusable field definition at site/fields/{name}/index.json, holding a label, a type name, and default options. It is not what template field access returns.
Properties
| Property | Type | Description |
|---|---|---|
$name |
string |
The field's own name, e.g. body |
$type |
string |
The field type name, e.g. markdown |
$label |
string |
Admin-facing label |
$options |
array |
Default options (required, max_length, ...) |
Methods
| Method | Returns | Description |
|---|---|---|
optionFields() |
FieldValueSet |
This field's own options, resolved against its type's option schema |
FieldValue
What $page->fieldname actually returns, the per-render resolved value, combining a Field-like definition (name/type/label/options) with a FieldType inner instance. Never persisted, never registry-indexed.
Methods
| Method | Returns | Description |
|---|---|---|
decode() |
mixed |
The decoded value (HTML for markdown, etc.), delegates to the inner FieldType |
encode(mixed $value) |
mixed |
Encode a value for storage |
empty() |
bool |
True if the raw value is null, '', or [] |
type() |
FieldType |
The underlying FieldType instance |
render() |
string |
Render through the field type's render template |
url() |
string |
Hypermedia edit-fragment URL for this field, /page/@fieldname, not a file/image URL |
withKey(string $key) |
FieldValue |
New instance scoped to a collection record key |
__toString() |
string |
Returns (string) inner, decoded string |
Any method not declared directly on FieldValue (e.g. src(), srcset() on an image field) is forwarded to the inner FieldType via __call. But url() is declared directly on FieldValue, so calling it never reaches the inner file's own url(), it always returns the edit-fragment URL above. For a file/image's public URL, use the field bare ({$page->cover}) or ->src(...), never ->url().
Always check empty() before rendering optional fields, a FieldValue is always truthy in PHP regardless of its value:
{if !$page->cover->empty()}
<img src="{$page->cover}" alt="">
{/if}
FieldType
The class holding a field's actual encode/decode/empty/validate logic. No page context, no rendering, no persistence. Subclasses are named FieldType + the type name (FieldTypeMarkdown, FieldTypeImage, etc.), and the four primitives, FieldTypeText, FieldTypeNumber, FieldTypeList, FieldTypeObject, cover every JSON value shape.
Properties
| Property | Type | Description |
|---|---|---|
$value |
mixed |
The raw stored value |
$type |
string |
The type name |
Methods
| Method | Returns | Description |
|---|---|---|
decode() |
mixed |
Override to return the decoded value |
encode(mixed $value) |
mixed |
Override to encode a value for storage |
empty() |
bool |
True if value is null, '', or [] |
create(...) |
static |
Factory method, override to consume $options |
__toString() |
string |
Returns (string) $this->decode() |
Custom field types
Create site/fieldtypes/{type}/{type}.php:
<?php
namespace Phlat;
class FieldTypeRating extends FieldTypeNumber
{
public function decode(): mixed
{
return max(0, min(5, (int) $this->value));
}
public function stars(): string
{
$n = $this->decode();
return str_repeat('★', $n) . str_repeat('☆', 5 - $n);
}
}
Add site/fieldtypes/{type}/index.json with a title/description for the admin UI. For |render output, add site/views/fields/{type}.latte, it receives $field (the FieldValue) and $value (the decoded value). Reference the type from a field definition:
// site/fields/score/index.json
{ "label": "Score", "type": "rating", "options": {} }
Types in site/fieldtypes/ override matching types in phlat/fieldtypes/.
DataFile
DataFile extends File and wraps a JSON file on disk. Decodes the file into a $data array on construction. Provides dot-notation key access.
Properties
| Property | Type | Description |
|---|---|---|
$data |
array |
Decoded JSON as an associative array |
Methods
| Method | Returns | Description |
|---|---|---|
get(string $key, mixed $default = null) |
mixed |
Dot-notation access: get('meta.title') |
merge(DataFile $other) |
static |
New DataFile with deeply merged data |
write(array $data) |
static |
Merge, write, and return a fresh DataFile |
__get(string $key) |
mixed |
Shorthand for get($key) |
PageCollection
An iterable, filterable list of Page instances (RecordCollection is the generic base, for other Record types). Returned by Page::children(), Page::parents(), and Page::find(). Implements IteratorAggregate and Countable, use directly in {foreach} loops.
Methods
| Method | Returns | Description |
|---|---|---|
count() |
int |
Number of items |
first() |
?Page |
First item, or null |
last() |
?Page |
Last item, or null |
nth(int $index) |
?Page |
Item at a given index, or null |
reverse() |
static |
New collection, reversed |
limit(int $n) |
static |
Cap iteration at N items |
offset(int $n) |
static |
Skip the first N items |
filter(callable $fn) |
static |
New collection of items where callback returns true |
sort(callable $fn) |
static |
New collection sorted by comparison callback |
pluck(string $field) |
array |
Values of one field across all items |
There is no find() on the collection itself. The query DSL lives on Page::children(string $query), Page::find(string $query), and Registry::find(string $query), pass the query directly into one of those instead of chaining ->find() after the fact:
$page->children('status=published, sort=-created') // correct
$page->children()->find('status=published') // wrong, no such method
Query syntax
Conditions are comma-separated. All must match (AND logic). One sort= clause is allowed.
$page->children('status=published, sort=-created');
$page->children('tags*=php, sort=title');
| Operator | Meaning |
|---|---|
= |
Exact match |
!= |
Not equal |
*= |
Contains |
^= |
Starts with |
$= |
Ends with |
< > <= >= |
Comparison |
Sort: sort=field ascending, sort=-field descending. Page objects are loaded from the registry lazily, only when iterated.