Skip to main content

Error handling

The hierarchy

Every exception the package throws extends EntityException, so one catch covers all of them.

EntityException
├── MappingException
├── TypeConversionException
├── CreateEntityException
├── HydrationException
├── PersistenceException
├── InvalidEntityException
├── InvalidIdentifierException
├── EntityNotFoundException
└── EntityDatabaseException
ExceptionThrown when
MappingExceptionA class cannot be mapped: no identifier, contradictory attributes, an untyped or unsupported property, two properties on one column.
TypeConversionExceptionNo converter handles a type, or a value cannot cross the boundary in either direction.
CreateEntityExceptionAn entity could not be instantiated to hydrate into.
HydrationExceptionA row cannot fill an entity: a column missing, a NULL in a property that refuses it, a value the property's type rejects.
PersistenceExceptionA write cannot be made or was refused: an uninitialised property, no generated identifier returned, an identifier matching more than one row.
InvalidEntityExceptionA store was handed an object of another class.
InvalidIdentifierExceptionThe identifier passed to find() does not fit the entity's.
EntityNotFoundExceptionfindOrFail() found nothing.
EntityDatabaseExceptionA read or a connection failed in the database layer.

MappingException and TypeConversionException are raised while reading a class, so a class that cannot be mapped fails on the first of() call rather than when a row is read.

All nine are final. EntityException is the one open class, and it is what to catch to catch everything.

Context

EntityException carries an array<string, mixed> of diagnostic data alongside the message.

try {
$articles->insert($article);
} catch (EntityException $exception) {
$logger->error($exception->getMessage(), $exception->getContext() + [
'exception' => $exception,
]);
}

The exception key must contain the caught exception even when the context already has an entry under that name.

addContext() merges more in and returns the exception, so a layer that knows something the thrower did not can add it and rethrow:

throw $exception->addContext(['request' => $requestId]);

What each carries

SourceKeys
Anything about one entityentity
A property-level failureentity, property
A column-level failureentity, property, column
A duplicate columnentity, column, firstProperty, secondProperty
Contradictory attributesentity, property, attributes
An unsupported property typeentity, property, type
A failed read or connectionentity, operation
Too many rows affectedentity, operation, expectedMaximum, actual
The wrong classexpected, actual
A conversionexpected and actual, or type and value
Nothing foundtype, identifier
A composite identifierentity, property, or properties

operation is insert, update, delete, get, first, cursor, exists, count or connect.

danger

Context is written to logs, and a conversion failure records the value it could not convert. Do not map a secret to a column whose converter can fail, and treat value as sensitive when configuring a logger.

The original is always attached

An exception raised by dirthara/database is wrapped, not swallowed. The original is the previous exception, so the driver's own message and SQLSTATE stay reachable.

catch (EntityDatabaseException $exception) {
$exception->getPrevious(); // the DatabaseException from dirthara/database
}

Wrapping is deliberate: a caller should not need dirthara/database in a catch block to handle a failure this package caused. Reflection failures are wrapped the same way, with the ReflectionException as previous.

OperationWrapped as
insert(), update(), delete()PersistenceException
get(), first(), cursor(), exists(), count()EntityDatabaseException
Resolving a connection in of()EntityDatabaseException, operation of connect

Catching the right thing

The useful split is between a mapping that is wrong and a row that is:

use Dirthara\Entity\Exception\MappingException;
use Dirthara\Entity\Exception\HydrationException;

try {
$articles->all();
} catch (MappingException $exception) {
// the class is mapped wrongly; fix the code
} catch (HydrationException $exception) {
// the class is fine and this row does not fit it; the schema drifted
}

HydrationException is the one that tells you the database and the mapping disagree — a column renamed underneath you, or a column made nullable while the property still refuses null.

EntityNotFoundException is the odd one out: it is an expected outcome, not a fault. Use find() and check for null when absence is ordinary, and findOrFail() only where absence really is exceptional.