Objects and types
The framework has a small, deliberate object model. Only three kinds of
thing are ever built with new: a Data object, a
Union object, and an exception. Everything else is a class of static
functions. Data carries values, static functions carry behaviour, and every function is
fully typed and documented so its contract reads from its signature.
What is constructed and what is not
Only Data objects, Union objects and exceptions are
instantiated. Everything else, a service class, a page, a Database class or
a Ui class, is a class of static functions that is never constructed; you
call its functions directly as ShopProduct::Load($id). This split is the
whole model: objects hold data, static functions do work, and there is no third category
of stateful service instance to manage.
The framework does not use PHP namespaces. Instead of a namespace, related functions are grouped onto a static-function class, and the class name together with its module prefix does the work a namespace would. Every function stays reachable by a single, predictable name, and the grouping stays visible in the name itself. See Naming for how those names are formed.
The Data object format
A Data object declares its fields as typed readonly
constructor parameters and holds nothing more. Because the fields are read-only, a data
object is a fixed value once built: a plain shape any code can pass around and trust.
This is a product in the Shop module:
<?php
final class ShopProductData
{
function __construct(
readonly int $id = 0,
readonly string $name = '',
readonly int $price = 0,
readonly int $stock = 0,
readonly string|null $optionalDescription = null,
readonly int $added = 0,
) {}
}
Build one with positional arguments in declared order. A field that can be null is
named with an optional prefix and typed TYPE|null, so
optionalDescription may be null while name and
price are guaranteed present and read directly with no null check. See
Naming for the optional convention.
The Union object format
Some values are not a fixed set of fields but a choice between alternatives: a stored
scalar is either an integer or a float or a boolean or a string; a result is either a
success or a failure. A Union object models that choice. It declares one
field per alternative, every field typed TYPE|null and defaulting to null,
with a private constructor so the only way to build one is a static factory. There
is one factory per variant, and each factory sets exactly one field, so a finished union
always has exactly one non-null field: that field is the variant.
This is the real value union from the StorageObject module. A stored scalar is exactly one of integer, float, boolean or string:
<?php
final class StorageObjectValueUnion {
private function __construct(
readonly int|null $integer = null,
readonly float|null $float = null,
readonly bool|null $boolean = null,
readonly string|null $string = null,
) {}
static function Integer(int $value): self { return new self($value, null, null, null); }
static function Float(float $value): self { return new self(null, $value, null, null); }
static function Boolean(bool $value): self { return new self(null, null, $value, null); }
static function StringValue(string $value): self { return new self(null, null, null, $value); }
}
You build a union by naming the variant, and you read it back by testing which field is non-null. Exactly one is set, so the checks are exhaustive and there is no separate tag to keep in sync:
$value = StorageObjectValueUnion::Integer(42);
if ($value->integer !== null) {
echo 'integer ' . $value->integer;
} elseif ($value->string !== null) {
echo 'string ' . $value->string;
}
The private constructor is the point: it removes every way to build an invalid value with two fields set or none set. A caller cannot make a nonsense union, so any code that receives one can trust that exactly one variant is present.
No methods on Data or Union objects
A data object carries no behaviour: no checks, no factories, no formatting, no
accessors. Just fields and a constructor. Behaviour that acts on those values lives on
the module's service classes and its Database classes, and reads the fields
directly. Keeping the value a plain shape is what lets any code pass it around and trust
it.
final class InvoiceData {
public function __construct(
public readonly int $total,
public readonly int $paid,
) {}
}
// behaviour lives on a service class, not on the data object
final class Invoice {
public static function isSettled(InvoiceData $invoice): bool {
return $invoice->paid >= $invoice->total;
}
}
A union object carries no behaviour either, with one narrow exception. Because its constructor is private it needs a static factory per variant as the only way to build each one. Those factories are construction, not behaviour: they set one field and return the value. A union has nothing else on it.
Never return a raw object
A function never returns an anonymous or ad-hoc object. Do not hand back a
stdClass, and do not build a one-off object literal to carry a few values
out of a function. When a function needs to return a structured value it returns a
Data object, or a Union object when the result is one of
several alternatives. Both are named types the caller can read, pass on and trust.
// no: an ad-hoc object with no type
return (object) ['total' => $total, 'paid' => $paid];
// yes: a named data object
return new InvoiceData($total, $paid);
Returning an array is fine, including an array of data objects. A query that reads many
rows returns a plain array of Data objects; it is the
individual value in the collection that must be a named type, not the collection
itself.
Every function is typed and documented
Types and documentation are how a function states its contract, so a caller need not
read its body. Every argument is given its type, every function declares its return type
even when that type is void, and every function has a doc comment above it
that says what the function does, describes each parameter, states what it returns, and
declares each exception it can throw on its own @throws line.
/**
* Load a product by its id.
*
* @param int $id The product id to look up.
* @return ShopProductData The product row.
* @throws ShopNotFoundException When no product has that id.
*/
function loadProduct(int $id): ShopProductData
{
$optionalProduct = ShopProductDatabase::find($id);
if ($optionalProduct === null) {
throw new ShopNotFoundException($id);
}
return $optionalProduct;
}
The @throws lines matter because throwing is how the framework handles
missing data. A caller reads them to know what it must be ready to handle, and the code
below stays a straight happy path. See
Errors and the happy path for how those exceptions are
caught high up rather than checked at every call site.
Next: Declarative code.