talleu / trigger-mapping
A Symfony bundle to map SQL triggers
Package info
github.com/clementtalleu/trigger-mapping
Type:symfony-bundle
pkg:composer/talleu/trigger-mapping
Requires
- php: >=8.2
- doctrine/dbal: ^3.0 || ^4.0
- doctrine/doctrine-bundle: ^2.7 || ^3.0
- doctrine/orm: ^2.16 || ^3.0
- symfony/config: ^6.0 || ^7.0 || ^8.0
- symfony/console: ^6.0 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.0 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.0 || ^7.0 || ^8.0
Requires (Dev)
- doctrine/doctrine-migrations-bundle: ^3.2 || ^4.0
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11.0
- symfony/framework-bundle: ^7.2 || ^8.0
- symfony/maker-bundle: ^1.50
- symfony/test-pack: ^1.2
- symfony/var-dumper: ^7.0 || ^8.0
- symfony/yaml: ^7.3 || ^8.0
Suggests
- doctrine/doctrine-migrations-bundle: ^3.2 || ^4.0 β required only for the `migrations: true` configuration (auto-generate migration files for your triggers).
README
Map, validate, version-control and deploy your database SQL triggers declaratively from your Doctrine entities β with PHP attributes.
namespace App\Entity; use Talleu\TriggerMapping\Attribute\Trigger; use App\Triggers\UpdateUserTimestamp; #[ORM\Entity] #[Trigger( name: 'trg_user_updated_at', function: 'fn_update_timestamp', on: ['INSERT', 'UPDATE'], when: 'BEFORE', scope: 'ROW', className: UpdateUserTimestamp::class, )] class User { /* ... */ }
Your trigger schema becomes declarative, reviewable in PRs, version-controlled alongside your code, and validated by your CI. No more SQL drift between branches, environments and teammates.
Table of contents
- Why this bundle?
- Compatibility matrix
- Installation
- Configuration
- The two storage strategies
- Workflows
- Commands reference
- The
#[Trigger]attribute - Multi-platform support
- Doctrine Migrations integration (optional)
- Security
- Contributing
- License
Why this bundle?
Database triggers are powerful (cascading audit logs, denormalized counters, soft deletes, history tables, hierarchical constraintsβ¦) but they are invisible from your application code. They live somewhere in the DB, often only known to the dev who wrote them, and slowly drift between environments.
This bundle solves that:
- π Declarative mapping via
#[Trigger]attributes on your Doctrine entities β visible at code-review time - β
triggers:schema:validateverifies the DB matches the mapping (perfect for CI) - π§
make:triggergenerates the boilerplate interactively - π
triggers:mapping:updateimports existing legacy triggers into your codebase - βοΈ
triggers:schema:diffscaffolds the SQL/PHP files from your attributes - π
triggers:schema:updatedeploys local changes to the DB (dev/CI) - π
triggers:schema:showpreviews the resolved SQL without touching the DB - βοΈ Optional Doctrine Migrations integration β
up/downmigration files generated automatically when you havedoctrine/doctrine-migrations-bundleinstalled - ππ¬πͺ First-class support for PostgreSQL, MySQL/MariaDB and SQL Server
π Compatibility
| Component | Supported |
|---|---|
| PHP | 8.2 Β· 8.3 Β· 8.4 |
| Symfony | 6.4 LTS Β· 7.x Β· 8.0 |
| Doctrine ORM | ^2.16 Β· ^3.0 (ready for ^4.0) |
| Doctrine DBAL | ^3.0 Β· ^4.0 |
| Doctrine Migrations Bundle | ^3.2 Β· ^4.0 β optional, only required if migrations: true |
| PostgreSQL | β₯ 12 (full feature parity from 14, automatic fallback for older versions) |
| MySQL / MariaDB | MySQL 5.7+ Β· MariaDB 10.5+ (10.11+ for STATEMENT-level triggers) |
| SQL Server | 2016+ (CREATE OR ALTER) β older versions partially supported |
The CI runs every push/PR against the full matrix (PHP Γ Symfony Γ Platform Γ ORM 2 vs 3 Γ DBAL 3 vs 4 Γ with/without migrations-bundle), so you can rely on what is announced above.
π Installation
1. Install the bundle
composer require talleu/trigger-mapping
2. Register it in config/bundles.php
return [ // ... Talleu\TriggerMapping\Bundle\TriggerMappingBundle::class => ['all' => true], ];
That's it β the bundle is ready to use with sensible defaults.
3. Optional β install Doctrine Migrations Bundle
Only needed if you want trigger changes to be automatically wired into Doctrine migrations (migrations: true in the bundle config):
composer require doctrine/doctrine-migrations-bundle
Without it, the bundle works perfectly well: trigger files are still generated, and you can apply them via triggers:schema:update --force or by adding the SQL to your migrations manually.
βοΈ Configuration
The bundle ships with sane defaults β you don't need a config file to get started. To customize, create config/packages/trigger_mapping.yaml:
trigger_mapping: storage: # 'php' (default) β generates PHP classes returning the SQL. # 'sql' β generates raw .sql files. Choose what fits your team's workflow. type: 'php' # Where the generated trigger files live. # Defaults to '%kernel.project_dir%/triggers'. directory: '%kernel.project_dir%/triggers' # PHP namespace for generated classes (only relevant when type: 'php'). # Defaults to 'App\Triggers'. namespace: 'App\Triggers' # Generate Doctrine migrations automatically when the migrations-bundle is installed. # If migrations-bundle is missing, this option is silently ignored. migrations: true # Triggers you want the bundle to ignore at extraction & validation time # (typically: legacy triggers you do not own, third-party extensions, etc.). excludes: - audit_logs_trigger_we_dont_own - extension_xxx_trigger
π More: docs/config.md
πΎ The two storage strategies
You decide where the SQL of your triggers lives:
storage.type: 'php' (default) |
storage.type: 'sql' |
|
|---|---|---|
| What gets generated | A PHP class implementing MySQLTriggerInterface, PostgreSQLTriggerInterface or SQLServerTriggerInterface with getTrigger() / getFunction() |
A .sql file (and a functions/<name>.sql file on PostgreSQL) |
| Where it lives | App\Triggers\<TrgClassName> (configurable) |
%kernel.project_dir%/triggers/<name>.sql |
| Best for | Static analysis (PHPStan/Psalm), refactoring, IDE-friendly | DBAs editing raw SQL, psql -f-style workflows |
| Migrations integration | addSql(\App\Triggers\Foo::getTrigger()) |
addSql(file_get_contents(__DIR__ . '/../triggers/foo.sql')) |
You can also override the global setting per-trigger with #[Trigger(storage: 'sql')].
π More: docs/storing.md
π Workflows
Scenario 1 β "I have a legacy DB with triggers I want to bring under version control"
bin/console triggers:mapping:update --apply --create-files
The command will:
- List every trigger in the DB that has no
#[Trigger]mapping yet - Find the matching Doctrine entity (incl. ManyToMany join tables)
- Add the
#[Trigger]attribute to that entity, with all params filled in from the DB - Generate the corresponding PHP class (or
.sqlfile) with the actual SQL logic from the DB
Your triggers are now version-controlled and reviewable β you can edit them just like any code.
Scenario 2 β "I want to add a new trigger to my application"
bin/console make:trigger
The interactive wizard asks for the entity, the trigger name, the events, the timing, etc., then:
- Adds
#[Trigger]to the entity - Generates the trigger boilerplate (class or
.sqlfile) - Optionally generates a Doctrine migration
Edit the generated getTrigger() body to put your real SQL, then run triggers:schema:update --force to deploy it (or run the migration in production).
Scenario 3 β "I changed an existing trigger and want to deploy it"
Edit the generated PHP class (or .sql file) and choose your deployment path:
# Dev / CI: apply directly to the database bin/console triggers:schema:update --force # Production: generate a migration and run doctrine:migrations:migrate bin/console triggers:schema:diff --apply bin/console doctrine:migrations:migrate
Scenario 4 β "I want my CI to fail when DB and code drift apart"
Just add this step to your CI pipeline:
- run: bin/console triggers:schema:validate
If any trigger is missing in DB, missing in mapping, or has divergent parameters, the command exits with a non-zero status code and prints a clear table of the discrepancies.
π Commands reference
| Command | Alias | What it does |
|---|---|---|
triggers:schema:validate |
t:s:v |
Compare mapping with DB and exit non-zero on drift. Read-only. |
triggers:schema:show |
t:s:show |
Print the resolved SQL of mapped triggers. Read-only, no DB connection used. |
triggers:schema:diff |
t:s:d |
Generate the missing trigger files / migrations from the mapping. |
triggers:schema:update |
t:s:u |
Apply local trigger files to the DB (dry-run by default, use --force). |
triggers:mapping:update |
t:m:u |
Import unmapped DB triggers into entity attributes. |
make:trigger |
β | Interactive wizard to scaffold a new trigger. Requires symfony/maker-bundle (dev). |
All commands accept --entity App\Entity\Foo to scope to a single entity.
triggers:schema:validate
bin/console triggers:schema:validate
bin/console triggers:schema:validate --entity "App\Entity\User"
The "health check" for your trigger setup. Read-only. Reports four classes of problems:
- Missing in DB: trigger is mapped but not deployed β run a migration
- Missing in mapping: trigger exists in DB but is unknown to your code β run
triggers:mapping:update - Mismatched parameters: events / when / scope / function / table differ between attribute and DB
- Unmapped DB tables: DB triggers on tables that have no Doctrine entity β silently skipped (or use
excludesto be explicit)
π More: docs/schema_validate.md
triggers:schema:show
bin/console triggers:schema:show
bin/console triggers:schema:show --entity "App\Entity\User"
Prints the SQL that would be applied for each mapped trigger β without touching the database. Useful for:
- Code reviews ("show me the actual SQL behind these attributes")
- CI logs (proof of what is deployed)
- Debugging (resolved file paths and class loading)
triggers:schema:diff
bin/console triggers:schema:diff # dry-run, lists what would be generated bin/console triggers:schema:diff --apply # actually creates the files / migration bin/console triggers:schema:diff --apply --entity "App\Entity\User"
Code-first companion of validate: takes every #[Trigger] that has no DB counterpart and scaffolds the file (PHP class or .sql) plus, if migrations: true, a Doctrine migration. The generated body contains a TODO placeholder β you fill in the real SQL.
π More: docs/schema_diff.md
triggers:schema:update
bin/console triggers:schema:update # dry-run, shows the SQL bin/console triggers:schema:update --force # actually executes it bin/console triggers:schema:update --force --entity "App\Entity\User"
Deploys the trigger logic from your local files directly to the DB. Safer than it looks:
- Asks for confirmation in
--forcemode - Wraps the queries in a transaction on PostgreSQL and SQL Server (atomic deployment per trigger)
- Uses
CREATE OR REPLACE TRIGGERon PG β₯ 14, falls back toDROP IF EXISTS + CREATEon PG < 14 - Drops & re-creates on MySQL (no DDL transactions there)
- Throws clear
CouldNotFindTriggerSqlFileException/NotAnValidTriggerClassExceptionwhen the source is missing or invalid
π More: docs/schema_update.md
triggers:mapping:update
bin/console triggers:mapping:update # dry-run bin/console triggers:mapping:update --apply # only adds the #[Trigger] attribute bin/console triggers:mapping:update --apply --create-files # also creates the trigger files with the DB SQL
Inspects DB-side triggers without a mapping and brings them into your codebase. Detects:
- Direct table β entity matches via
getTableName() - ManyToMany join tables (sets
onTable: '<join_table>'on the owning side)
Triggers on tables that don't belong to any Doctrine entity are skipped with a warning (so DB extensions don't pollute your mapping).
π More: docs/mapping_update.md
make:trigger
bin/console make:trigger
Interactive wizard powered by symfony/maker-bundle. Step-by-step prompts for:
- The entity to attach the trigger to
- Trigger name
- (PostgreSQL) Function name
- Events:
INSERT,UPDATE,DELETE - (PostgreSQL) Scope:
ROWorSTATEMENT - Timing:
BEFORE/AFTER - Storage:
phporsql - (Optional) Generate a Doctrine migration
Validates each input early with friendly messages β invalid values throw \InvalidArgumentException with the list of allowed options.
π More: docs/make_trigger.md
π― The #[Trigger] attribute
use Talleu\TriggerMapping\Attribute\Trigger; #[ORM\Entity] #[Trigger( name: 'trg_audit_user', // SQL identifier (required) function: 'fn_audit_user', // PostgreSQL function name (PG-only, otherwise null) on: ['INSERT', 'UPDATE'], // INSERT, UPDATE, DELETE, TRUNCATE (PG-only) when: 'BEFORE', // BEFORE, AFTER, INSTEAD OF scope: 'ROW', // ROW, STATEMENT storage: 'php', // (optional) override the global storage type for this trigger className: UserAuditTrigger::class, // (optional) FQCN of the PHP trigger class onTable: 'user_role', // (optional) explicit table name, e.g. for ManyToMany join tables )] class User { /* ... */ }
The attribute is #[\Attribute(IS_REPEATABLE)] so you can stack multiple triggers on the same entity:
#[Trigger(name: 'trg_log_insert', on: ['INSERT'], when: 'AFTER', scope: 'ROW')] #[Trigger(name: 'trg_log_update', on: ['UPDATE'], when: 'AFTER', scope: 'ROW')] class User { /* ... */ }
Validation rules (enforced at instantiation)
The constructor validates every value eagerly and throws \InvalidArgumentException on the spot β meaning typos surface at boot, not at runtime in production.
| Field | Rule |
|---|---|
name, function, onTable |
Match /^[A-Za-z_][A-Za-z0-9_]{0,62}$/ (safe SQL identifier) |
when |
One of BEFORE, AFTER, INSTEAD OF (case-insensitive) |
scope |
One of ROW, STATEMENT (case-insensitive) |
on[] |
Each item: INSERT, UPDATE, DELETE, TRUNCATE (case-insensitive) |
storage |
php or sql (or null to inherit the global setting) |
Why so strict? Identifiers end up in generated SQL files, file paths and PHP migration source. The strict regex closes a wide range of attacks (SQL injection through
DROP TRIGGER, path traversal in storage paths, PHP code injection in generated migrations) at the boundary, in one place. See Security.
ππ¬πͺ Multi-platform support
The bundle works the same on three platforms but each one has its quirks. Here is what is supported (and what is intentionally rejected):
| PostgreSQL | MySQL / MariaDB | SQL Server | |
|---|---|---|---|
BEFORE / AFTER |
β | β | β (rejected with a clear message) |
INSTEAD OF |
β (on views) | β | β |
Multi-events (INSERT OR UPDATE OR DELETE) |
β | β (one event per trigger β rejected with a clear message) | β |
ROW / STATEMENT scope |
β | MySQL: ROW only Β· MariaDB β₯ 10.11: both | STATEMENT only (per-statement triggers) |
TRUNCATE event |
β | β | β |
| Trigger functions | β
(separate pg_proc) |
n/a (body inlined in trigger) | n/a (body inlined in trigger) |
| Multi-schema | β
(current_schemas(false)) |
n/a | β
(SCHEMA_NAME()) |
| FK / partition triggers filtering | β
(tgconstraint = 0, tgparentid = 0) |
n/a | β
(parent_class = 1) |
CREATE OR REPLACE TRIGGER |
PG β₯ 14 (auto-fallback to DROP + CREATE below) |
DROP + CREATE | CREATE OR ALTER (SQL Server β₯ 2016) |
| Transactional deploys | β | β (DDL implicit commits) | β |
How extraction works
PostgreSQL triggers are decoded from the pg_trigger.tgtype bitfield β exact, no text parsing, no fragile regex. SQL Server reads sys.triggers + sys.trigger_events + sys.sql_modules.definition (scoped to SCHEMA_NAME()). MySQL/MariaDB reads information_schema.TRIGGERS (incl. ACTION_ORIENTATION for STATEMENT support).
βοΈ Doctrine Migrations integration (optional)
When doctrine/doctrine-migrations-bundle is installed and migrations: true is set in the bundle config, every triggers:schema:diff --apply and make:trigger --migration automatically generates a Doctrine migration with both the up() and down() statements:
// Auto-generated Version20260101000000.php (excerpt) public function up(Schema $schema): void { $this->addSql(\App\Triggers\TrgUserUpdatedAt::getFunction()); $this->addSql(\App\Triggers\TrgUserUpdatedAt::getTrigger()); } public function down(Schema $schema): void { $this->addSql('DROP TRIGGER IF EXISTS trg_user_updated_at ON user;'); $this->addSql('DROP FUNCTION IF EXISTS fn_update_timestamp();'); }
Then deploy with the standard bin/console doctrine:migrations:migrate workflow β your triggers are part of the same atomic deployment as the rest of your schema changes.
β οΈ The generated SQL strings are produced via
var_export()β which means even an attacker-controlled trigger name (e.g. coming from a compromised legacy DB) cannot break out of the string literal and inject PHP code into your migration files.
π‘οΈ Security
The bundle takes adversarial inputs seriously. Several mitigations are in place by default:
- Strict identifier validation at the
#[Trigger]attribute boundary (regex^[A-Za-z_][A-Za-z0-9_]{0,62}$), as well as on data extracted from the database inTriggersDbExtractor. Names that don't match are skipped β they never reach the file system or the migration generator. var_export()is used to serialise SQL into PHP migration source code, so no characters can break out of the string literal.pdo_sqlsrv& ODBC validation of trigger metadata via parameterized queries / typed columns rather than string concatenation.- Path constraints: trigger names are also used as filesystem path components β invalid names cannot escape the configured
storage.directory. - Confirmation prompt in
triggers:schema:update --force, transaction wrapping on PG/SQL Server.
If you find a security issue, please report it privately via GitHub Security Advisories rather than a public issue.
π€ Contributing
Contributions are very welcome. The full contribution guide β including how to run the test suite locally with Docker, how to install pdo_sqlsrv for SQL Server, how to use the Makefile shortcuts β is in CONTRIBUTING.md.
Quick start:
git clone https://github.com/clementtalleu/trigger-mapping.git cd trigger-mapping composer install make docker-up # spawn MySQL, PostgreSQL, SQL Server containers make test-docker # run the full test suite (unit + 3 platforms) inside a php container
The CI runs every push/PR against:
- Unit tests (PHP 8.2/8.3/8.4 Γ Symfony 6.4/7.4/8.0)
- Functional tests (Γ MySQL/PostgreSQL/SQL Server)
- Legacy Doctrine (ORM ^2.16 + DBAL ^3)
- Without
doctrine-migrations-bundle(verifies the bundle stays usable when the optional dep is missing) - PHPStan level 8 + PHP-CS-Fixer
π License
MIT Β© ClΓ©ment Talleu and contributors.