Skip to content

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, and record["x"] on an absent key emits an undefined-key warning. Always use record["x"] ?? null in 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.