Hook Engine
The Hook Engine lets you attach behaviour to entity write operations without touching PHP source.
Wiring is stored in the syntec_hook table and is resolved at runtime by the
HookDispatcher. All writes (insert, update, archive/delete) funnel through EntityRepository,
so hooks apply universally — including nested-tree writes from forms and the reconciler.
Scope
v1 scope — entity lifecycle only. The five events below fire on any entity registered in the
platform. Form-level, view-level, column-level, and field-level scopes are planned for future
releases; the dispatcher, context, and syntec_hook wiring table are already scope-agnostic and
will reuse them.
Event catalog
Five events are fired in order around each write operation. The event names are fixed and
versioned (HookEvent enum).
| Event | Operation | When |
|---|---|---|
validate |
create / update | After input is decoded, before any DB write. Intended for cross-field validation. |
before_save |
create / update | Immediately before the INSERT or UPDATE. Use to compute derived fields or set defaults. |
after_save |
create / update | After the row is committed (inside the same transaction). |
before_delete |
delete (archive) | Before status is set to 3. |
after_delete |
delete (archive) | After the row is archived. |
Within each event, hook rows run in ascending (position, id) order.
When to use each event
validate — reject bad input before anything is written. A validate expression returns
true to pass or an error-message string to fail; a code handler calls
$ctx->error($field, $message). All validate rows run, then a single ValidationException
(HTTP 422) is thrown if any error was accrued — so the caller sees every problem at once, not just
the first. Use it for cross-field rules the JSON schema can't express (e.g. "end date after start
date", "at least one contact method"). Do not mutate the record here — use before_save.
before_save — compute or default fields just before the INSERT/UPDATE. This is the only
event whose record mutations are persisted, so put all derivation here: slugs, display names,
denormalised copies, status stamps, normalising case/whitespace. record is mutable (via
target_field for tier-1, or $ctx->set() for tier-2). On update, before holds the pre-change
row so you can react to transitions.
after_save — react to a row that is now written, inside the same transaction. Use it for
side effects that must be atomic with the write: inserting a child/audit/outbox row, bumping a
counter, appending to a log. Because it shares the transaction, throwing here (or accruing a
validation error) still rolls the whole write back. Do not rely on it for "fire-and-forget"
external calls — those belong in an outbox row consumed after commit.
before_delete — guard or prepare for an archive (delete is soft: status → 3). Use it to
block deletion (validate-style error) when the row is referenced, or to cascade/clean up
dependents first. Note the caveat below: if the row is already archived/absent, it fires with an
empty record.
after_delete — react to a completed archive (same transaction). Use it to archive dependents
or write a tombstone/audit entry. It does not fire if the archive affected zero rows.
The syntec_hook wiring table
Each row represents one hook:
| Column | Type | Description |
|---|---|---|
entity |
varchar(80) | The physical table name the hook targets, e.g. syntec_user. |
event |
varchar(40) | One of the five event names above. |
position |
int | Execution order within the event (lower = earlier; ties broken by id). |
handler_tier |
varchar(16) | declarative for expression-based handlers; code for a PHP handler class named by FQN. |
expression |
text | Symfony ExpressionLanguage expression (tier-1 only). |
handler_key |
varchar(120) | The handler's class FQN, e.g. App\Crm\Hook\ComputeSlugHandler (tier-2 only). |
target_field |
varchar(80) | For a compute/default expression: the record field to write the result into. For a validate expression: the field name attached to the error (optional). |
condition |
text | Optional guard expression; evaluated before the handler runs — skip the row if it returns falsy. Same syntax as expression. |
provenance |
varchar(16) | Who created the row (seed, user, etc.). |
Standard platform columns (id, guid, created, modified, status) are also present.
Set status = 3 (archived) to disable a hook without deleting it.
Expression variables
Both expression and condition are evaluated by ExpressionEvaluator using Symfony
ExpressionLanguage. There is no eval — only the following variables are in scope:
| Variable | Type | Description |
|---|---|---|
record |
array | The current record (mutable; may already have been changed by an earlier hook in this event). |
before |
array|null | The row's before-image (only for update and delete operations; null for create). |
user |
int|null | The acting user's id, or null for system/anonymous writes. |
now |
string | Current UTC timestamp, e.g. "2026-06-27 14:30:00.000000". |
Built-in functions
Beyond ExpressionLanguage's default constant(), ExpressionEvaluator registers a small set of
multibyte-safe string helpers. Each coerces its argument to string, so pairing them with the
null-coalescing accessor is safe on a missing/null field:
| Function | Result |
|---|---|
upper(x) |
x uppercased (mb_strtoupper). |
lower(x) |
x lowercased (mb_strtolower). |
trim(x) |
x with leading/trailing whitespace removed. |
Warning-safe field access. The suite runs with
failOnWarning, andrecord["x"]on an absent key emits an undefined-key warning. Always userecord["x"] ?? nullin expressions and conditions for fields that may be missing.
Operators
Everything Symfony ExpressionLanguage supports is available in expression and condition. There
is no eval, no function calls beyond the ones listed above, and no access to PHP globals — only
the four variables and these operators.
| Category | Operators | Example |
|---|---|---|
| Arithmetic | + - * / % ** |
record['qty'] * record['price'] |
| String concat | ~ |
record['first'] ~ ' ' ~ record['last'] |
| Comparison | == != === !== < > <= >= |
record['total'] >= 100 |
| Logical | and / &&, or / \|\|, not / ! |
a and not b |
| Ternary / null-coalesce | ? :, ?:, ?? |
record['x'] ?? 'default' |
| Membership | in, not in |
record['status'] in [1, 2] |
| Range | .. |
record['score'] in 1..5 |
| Regex match | matches |
record['email'] matches '/@/' |
| Array / property | [...], . |
record['tags'][0], before.status |
| Bitwise | & \| ^ ~ << >> |
rarely needed |
| Constants | constant('PHP_INT_MAX') |
ExpressionLanguage built-in |
Note and/or/not are preferred over &&/||/! for readability, and both work. Booleans
returned by an expression on a validate hook must be exactly true to pass — any other
value (including a truthy string) is treated as the failure message.
Condition recipes
The condition column gates a row: the handler runs only when the condition evaluates truthy.
Common patterns:
# only on create (no before-image)
before == null
# only on update
before != null
# a field actually changed on update
before != null and (record['status'] ?? null) != (before['status'] ?? null)
# transitioning INTO a state (fires once, on the edge)
record['status'] == 2 and (before == null or before['status'] != 2)
# only when a field was supplied and non-empty
(record['email'] ?? null) != null and trim(record['email']) != ''
# act for a specific acting user (or system writes)
user == null # system/anonymous write
user != null # a real user
# value in a set
(record['kind'] ?? null) in ['lead', 'prospect']
Adding functions
The three string helpers live in ExpressionEvaluator::registerStringFunctions(). To expose more
functions to declarative hooks, register them there with a compiler and an evaluator closure
(same ExpressionLanguage::register() signature). Keep them pure and side-effect-free — a
declarative hook is meant to be safe to run inside the write path. For anything that needs
services, I/O, or non-trivial logic, use a tier-2 code handler instead.
Two handler tiers
Tier-1: Declarative (ExpressionLanguage)
Set handler_tier = 'declarative' and write an expression. No PHP deployment needed.
Compute / default — set target_field to the field to populate:
entity: syntec_user
event: before_save
handler_tier: declarative
expression: record['first_name'] ~ ' ' ~ record['last_name']
target_field: display_name
condition: (null == null) # always run — omit condition to run unconditionally
The result of the expression is written into record['display_name'] before the row is saved.
Validate — omit (or leave blank) target_field; the expression must return true or an
error message string:
entity: syntec_order
event: validate
handler_tier: declarative
expression: record['qty'] > 0 ? true : 'Quantity must be greater than zero.'
target_field: qty # optional: attaches the error to this field name
If the expression returns anything other than true, a validation error is accrued against
target_field (or the empty string if omitted).
Condition gate — any row (either tier) can be skipped by adding a condition expression:
entity: syntec_invoice
event: before_save
handler_tier: declarative
expression: now
target_field: locked_at
condition: record['status'] == 2 and (before == null or before['status'] != 2)
This stamp fires only when the record is transitioning into status 2.
Tier-2: Code handlers
Set handler_tier = 'code' and handler_key to the handler's class FQN. There is no
registration step and no core changes: HookHandlerResolver resolves handler_key straight to
a class.
The handler must implement HookHandler:
use Syntec\Core\Hook\{HookContext, HookHandler};
final class ComputeSlugHandler implements HookHandler
{
public function handle(HookContext $ctx): void
{
$ctx->set('slug', strtolower(str_replace(' ', '-', $ctx->get('name'))));
}
}
Ship it from your module as an autowired service — the core bundle registers container-wide
autoconfiguration (registerForAutoconfiguration(HookHandler::class)), so any autowired
HookHandler in any bundle is auto-tagged syntec.hook_handler and collected into a Symfony
tagged-service locator the resolver reads. That
gives the handler full DI: constructor-inject Doctrine\DBAL\Connection, ConfigStore, or any
other autowired service, and read the acting user (or other per-request context) off
HookContext->ctx rather than the constructor. Seed a handler_tier='code' row with
handler_key = ComputeSlugHandler::class and it fires on the next matching write — no bundle
boot() wiring required.
For dependency-free handlers — including on the DI-less engine and the test suite —
HookHandlerResolver falls back to new $fqn() when the class isn't found in the service
locator. This is how the shipped FileLogHookHandler example below resolves.
If handler_key does not resolve to a tagged service and is not an instantiable class
implementing HookHandler, the resolver throws \RuntimeException at dispatch time.
Shipped examples — syntec_data_column_test
Two example hooks are seeded on the syntec_data_column_test test-bench entity (by
Bootstrap::seedHookExamples()) as a live, end-to-end demonstration of both tiers. Insert a row
into that entity through the API and watch them fire.
1. before_save — uppercase text_col (tier-1 declarative):
entity: syntec_data_column_test
event: before_save
handler_tier: declarative
expression: upper(record["text_col"] ?? null)
target_field: text_col
condition: (record["text_col"] ?? null) != null # only when text_col was supplied
2. after_save — file log (tier-2 code):
entity: syntec_data_column_test
event: after_save
handler_tier: code
handler_key: Syntec\Core\Hook\Handler\FileLogHookHandler
handler_key is FileLogHookHandler::class. It has no constructor dependencies, so
HookHandlerResolver instantiates it directly with new $fqn() — no service registration
involved. It appends one line per save to sys_get_temp_dir()/syntec-hook-example.log, e.g.:
[2026-07-01 20:15:03.123456] after_save syntec_data_column_test guid=019ec3… text_col=HELLO
This is also the reference pattern for shipping your own code handlers: implement
HookHandler, autowire it as a service (auto-tagged syntec.hook_handler) if it needs DI, and
seed a handler_tier='code' row with handler_key set to the class FQN.
Validation errors → 422
After all rows for an event have run, the dispatcher checks HookContext::hasErrors().
If any errors were accrued (via $ctx->error($field, $message) from code handlers, or from a
validate expression returning a non-true value), a ValidationException is thrown. This
exception is caught by the API layer, rolls back the enclosing transaction, and returns HTTP 422
with a structured error body identical to schema-validation errors.
Known caveats
1. Audit diff does not include before_save compute fields on update.
The audit diff is built from the caller's payload compared against the before-image that was
fetched before hooks ran. If a before_save hook writes a new value into a field that was not
in the caller payload (e.g. auto-computing display_name from first_name/last_name on an
update that only touched email), the computed value is persisted to the database but does
not appear in the audit diff. The audit records only what the caller sent.
2. before_delete fires with an empty record if the row is already archived.
If the target row does not exist or has already been soft-deleted (status = 3), the
before-image fetch returns null. before_delete is fired with $ctx->record = [] and
$ctx->before = null. after_delete will not fire in that case because the archive query
affects zero rows.
Performance
An entity with zero syntec_hook rows incurs one indexed SELECT per fire point returning
an empty result set. The index syntec_hook_entity_event (entity, event, status) makes this
cheap. Do not add per-write overhead beyond this gated query.