Database layer conventions
One database class per entity, and every query returns a data object.
Give each entity its own <Thing>Database class. Every query returns a
data object built by a private fromRow mapper that uses the To::
helpers to convert each column, so a field's nullability matches its column. Admin queries
expose ids; front queries do not. Reconcile data from different modules in PHP rather than
joining their tables.
final class MemberDatabase {
public static function findByEmail(string $email): ?MemberData {
$row = Database::Row(
'SELECT name, email, verified_at FROM member WHERE email = ?',
[$email]
);
if ($row === null) {
return null;
}
return self::fromRow($row);
}
private static function fromRow(array $row): MemberData {
$name = To::String($row['name']);
$email = To::String($row['email']);
$optionalVerified = To::OptionalDateTime($row['verified_at']);
return new MemberData($name, $email, $optionalVerified);
}
}
See The database layer for the how-to and the Database module for the reference.
Table naming
A table name is singular, lowercase, and underscore-separated, and it begins with the name of
the module that owns it. One member is a row in member; the tables that hang off
it are member_auth, member_code and member_folder. The
singular reads naturally against a single row, the lowercase and underscores avoid quoting and
case surprises across databases, and the module prefix tells you at a glance which module owns
the table.
member
member_auth
member_code
member_folder
Because the important part is on the left, a module's tables sort together as one block in any alphabetical listing, so a schema dump reads as grouped families rather than scattered names. See Naming for the general left-to-right ordering rule that this follows.
All SQL lives in a Database class
Every database access is in a class named <Something>Database. There
is no SQL anywhere else: not in a page, a service, an Api handler, or a data object. If
you need to read or write a table, add a named function to that table's Database class and
call it. This keeps every column name and query in one place per entity.
Rows become objects through fromRow
Any function that fetches a row returns a data object built by the class's private
fromRow mapper, using the To:: helpers to convert each column so
a field's nullability matches its column. Callers never see a raw row or a column name.
The worked how-to is The database layer.
A single-record function returns the object or throws
A function that fetches exactly one record promises exactly one: its return type is the
data object, not a nullable. When the row is not found it throws
DatabaseErrorNoRow rather than returning null. The signature
alone tells the caller the value is present, so no null check follows.
static function Get(int $memberId): MemberData // throws DatabaseErrorNoRow if absent
{
return Database::SelectRow('member', 'MemberID', $memberId, MemberDatabase::fromRow(...));
}
A list function may return an empty array
A function that returns many records returns an array of data objects, and an empty array is a normal result, not an error. Never throw because a list came back empty; the caller handles zero rows the same way it handles any other count.
Optional lookups start with "optional"
If a single lookup may legitimately find nothing, the function name starts with
optional and it returns DataType|null, never
?DataType. Any variable or data-object field that holds such a value also
starts with optional, so a name without that prefix is guaranteed present and
needs no null check.
static function optionalByEmail(string $email): MemberData|null
{
$where = (object)['MemberEmail' => $email];
return Database::SelectRowIfExistsWhere('member', $where, MemberDatabase::fromRow(...));
}
No cross-module joins
Never JOIN tables that belong to different modules. Only join tables inside
the same module. When two modules' data must meet, gather ids from one and load the other
in a bulk query, then cross-reference in PHP.
The reason is coupling. A join hard-wires two modules' tables into one statement, so neither can move. Keeping them apart lets modules stay loosely coupled and lets a growing site put different modules in different databases, where a join would be impossible. See Loose coupling for the principle and The database layer for the bulk-id pattern.
Throw for missing data, keep the happy path
Prefer throwing when expected data is missing. Then the body of a function reads as a
straight happy path and the exception is caught high up, in the Api handlers and routing,
rather than being repeated as a null check at every call site. Do not scatter null checks
through the code. The one exception is a case where null is genuinely expected and you
have real logic for it: that is exactly what the optional naming marks.
Next: Rendering pages.