diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml
index 36ee453fe..dc74f4e4d 100644
--- a/.roave-backward-compatibility-check.xml
+++ b/.roave-backward-compatibility-check.xml
@@ -9,5 +9,6 @@
#(.*)Zenstruck\\Foundry\\Utils\\Rector(.*)#
#(.*)initializeInternal(.*)#
#\[BC\] CHANGED: Method getIdentifierValues\(\) of class Zenstruck\\Foundry\\Persistence\\PersistenceStrategy became final#
+ #"Zenstruck\\Foundry\\Test\\(CommonResetDatabase|CommonFactories)" could not be found in the located source(.*)#
diff --git a/bin/tools/bc-check/composer.json b/bin/tools/bc-check/composer.json
index 54f4ce2f2..835419a34 100644
--- a/bin/tools/bc-check/composer.json
+++ b/bin/tools/bc-check/composer.json
@@ -1,5 +1,5 @@
{
"require": {
- "roave/backward-compatibility-check": "^8.15"
+ "roave/backward-compatibility-check": "^8.17"
}
}
diff --git a/bin/tools/bc-check/composer.lock b/bin/tools/bc-check/composer.lock
index ed808c7e7..6339d0089 100644
--- a/bin/tools/bc-check/composer.lock
+++ b/bin/tools/bc-check/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "120355f87acd7a5304f83060fcc63639",
+ "content-hash": "fcc461da762c8ce11050eac34df0cfe2",
"packages": [
{
"name": "azjezz/psl",
diff --git a/bin/tools/psalm/composer.lock b/bin/tools/psalm/composer.lock
index f627661a5..b3b762f64 100644
--- a/bin/tools/psalm/composer.lock
+++ b/bin/tools/psalm/composer.lock
@@ -599,24 +599,27 @@
},
{
"name": "amphp/serialization",
- "version": "v1.0.0",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/amphp/serialization.git",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1"
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=7.4"
},
"require-dev": {
- "amphp/php-cs-fixer-config": "dev-master",
- "phpunit/phpunit": "^9 || ^8 || ^7"
+ "amphp/php-cs-fixer-config": "^2",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
},
"type": "library",
"autoload": {
@@ -651,9 +654,15 @@
],
"support": {
"issues": "https://github.com/amphp/serialization/issues",
- "source": "https://github.com/amphp/serialization/tree/master"
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0"
},
- "time": "2020-03-25T21:39:07+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-05T15:59:53+00:00"
},
{
"name": "amphp/socket",
@@ -1038,22 +1047,22 @@
},
{
"name": "danog/advanced-json-rpc",
- "version": "v3.2.2",
+ "version": "v3.2.3",
"source": {
"type": "git",
"url": "https://github.com/danog/php-advanced-json-rpc.git",
- "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb"
+ "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/aadb1c4068a88c3d0530cfe324b067920661efcb",
- "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb",
+ "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91",
+ "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91",
"shasum": ""
},
"require": {
"netresearch/jsonmapper": "^5",
"php": ">=8.1",
- "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0"
+ "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6"
},
"replace": {
"felixfbecker/php-advanced-json-rpc": "^3"
@@ -1084,9 +1093,9 @@
"description": "A more advanced JSONRPC implementation",
"support": {
"issues": "https://github.com/danog/php-advanced-json-rpc/issues",
- "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.2"
+ "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3"
},
- "time": "2025-02-14T10:55:15+00:00"
+ "time": "2026-01-12T21:07:10+00:00"
},
{
"name": "daverandom/libdns",
@@ -1171,29 +1180,29 @@
},
{
"name": "doctrine/deprecations",
- "version": "1.1.5",
+ "version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
- "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
- "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
- "phpunit/phpunit": "<=7.5 || >=13"
+ "phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
- "doctrine/coding-standard": "^9 || ^12 || ^13",
- "phpstan/phpstan": "1.4.10 || 2.1.11",
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
@@ -1213,9 +1222,9 @@
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
- "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
- "time": "2025-04-07T20:06:18+00:00"
+ "time": "2026-02-07T07:09:04+00:00"
},
{
"name": "felixfbecker/language-server-protocol",
@@ -1394,20 +1403,20 @@
},
{
"name": "league/uri",
- "version": "7.7.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
- "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807"
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
- "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
- "league/uri-interfaces": "^7.7",
+ "league/uri-interfaces": "^7.8.1",
"php": "^8.1",
"psr/http-factory": "^1"
},
@@ -1421,11 +1430,11 @@
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"ext-uri": "to use the PHP native URI class",
- "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
- "league/uri-components": "Needed to easily manipulate URI objects components",
- "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-components": "to provide additional tools to manipulate URI objects components",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
"php-64bit": "to improve IPV4 host parsing",
- "rowbot/url": "to handle WHATWG URL",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
@@ -1480,7 +1489,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri/tree/7.7.0"
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
@@ -1488,20 +1497,20 @@
"type": "github"
}
],
- "time": "2025-12-07T16:02:06+00:00"
+ "time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
- "version": "7.7.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
- "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c"
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
- "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
@@ -1514,7 +1523,7 @@
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"php-64bit": "to improve IPV4 host parsing",
- "rowbot/url": "to handle WHATWG URL",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
@@ -1564,7 +1573,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0"
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
@@ -1572,20 +1581,20 @@
"type": "github"
}
],
- "time": "2025-12-07T16:03:21+00:00"
+ "time": "2026-03-08T20:05:35+00:00"
},
{
"name": "netresearch/jsonmapper",
- "version": "v5.0.0",
+ "version": "v5.0.1",
"source": {
"type": "git",
"url": "https://github.com/cweiske/jsonmapper.git",
- "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c"
+ "reference": "980674efdda65913492d29a8fd51c82270dd37bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c",
- "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c",
+ "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb",
+ "reference": "980674efdda65913492d29a8fd51c82270dd37bb",
"shasum": ""
},
"require": {
@@ -1621,9 +1630,9 @@
"support": {
"email": "cweiske@cweiske.de",
"issues": "https://github.com/cweiske/jsonmapper/issues",
- "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0"
+ "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1"
},
- "time": "2024-09-08T10:20:00+00:00"
+ "time": "2026-02-22T16:28:03+00:00"
},
{
"name": "nikic/php-parser",
@@ -1738,16 +1747,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
- "version": "5.6.6",
+ "version": "6.0.3",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
- "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
- "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
"shasum": ""
},
"require": {
@@ -1755,8 +1764,8 @@
"ext-filter": "*",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2",
- "phpdocumentor/type-resolver": "^1.7",
- "phpstan/phpdoc-parser": "^1.7|^2.0",
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
"webmozart/assert": "^1.9.1 || ^2"
},
"require-dev": {
@@ -1766,7 +1775,8 @@
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
- "psalm/phar": "^5.26"
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
},
"type": "library",
"extra": {
@@ -1796,44 +1806,44 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
},
- "time": "2025-12-22T21:13:58+00:00"
+ "time": "2026-03-18T20:49:53+00:00"
},
{
"name": "phpdocumentor/type-resolver",
- "version": "1.12.0",
+ "version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
- "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
- "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
- "php": "^7.3 || ^8.0",
+ "php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
- "phpstan/phpdoc-parser": "^1.18|^2.0"
+ "phpstan/phpdoc-parser": "^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
- "rector/rector": "^0.13.9",
- "vimeo/psalm": "^4.25"
+ "psalm/phar": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-1.x": "1.x-dev"
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
}
},
"autoload": {
@@ -1854,22 +1864,22 @@
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
- "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
},
- "time": "2025-11-21T15:09:14+00:00"
+ "time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpstan/phpdoc-parser",
- "version": "2.3.0",
+ "version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": ""
},
"require": {
@@ -1901,9 +1911,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
},
- "time": "2025-08-30T15:50:23+00:00"
+ "time": "2026-01-25T14:56:51+00:00"
},
{
"name": "psr/container",
@@ -2190,29 +2200,29 @@
},
{
"name": "sebastian/diff",
- "version": "7.0.0",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
+ "reference": "9c957d730257f49c873f3761674559bd90098a7d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
- "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d",
+ "reference": "9c957d730257f49c873f3761674559bd90098a7d",
"shasum": ""
},
"require": {
- "php": ">=8.3"
+ "php": ">=8.4"
},
"require-dev": {
- "phpunit/phpunit": "^12.0",
+ "phpunit/phpunit": "^13.0",
"symfony/process": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "7.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -2245,15 +2255,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"security": "https://github.com/sebastianbergmann/diff/security/policy",
- "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
+ "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
+ "type": "tidelift"
}
],
- "time": "2025-02-07T04:55:46+00:00"
+ "time": "2026-04-05T12:02:33+00:00"
},
{
"name": "spatie/array-to-xml",
@@ -2325,16 +2347,16 @@
},
{
"name": "symfony/console",
- "version": "v8.0.3",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587"
+ "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/6145b304a5c1ea0bdbd0b04d297a5864f9a7d587",
- "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587",
+ "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7",
+ "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7",
"shasum": ""
},
"require": {
@@ -2391,7 +2413,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.0.3"
+ "source": "https://github.com/symfony/console/tree/v8.0.8"
},
"funding": [
{
@@ -2411,7 +2433,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-23T14:52:06+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -2482,16 +2504,16 @@
},
{
"name": "symfony/filesystem",
- "version": "v8.0.1",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "d937d400b980523dc9ee946bb69972b5e619058d"
+ "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d",
- "reference": "d937d400b980523dc9ee946bb69972b5e619058d",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a",
+ "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a",
"shasum": ""
},
"require": {
@@ -2528,7 +2550,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v8.0.1"
+ "source": "https://github.com/symfony/filesystem/tree/v8.0.8"
},
"funding": [
{
@@ -2548,7 +2570,7 @@
"type": "tidelift"
}
],
- "time": "2025-12-01T09:13:36+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -3054,16 +3076,16 @@
},
{
"name": "symfony/string",
- "version": "v8.0.1",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc"
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc",
- "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc",
+ "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963",
+ "reference": "ae9488f874d7603f9d2dfbf120203882b645d963",
"shasum": ""
},
"require": {
@@ -3120,7 +3142,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.1"
+ "source": "https://github.com/symfony/string/tree/v8.0.8"
},
"funding": [
{
@@ -3140,20 +3162,20 @@
"type": "tidelift"
}
],
- "time": "2025-12-01T09:13:36+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "vimeo/psalm",
- "version": "6.14.3",
+ "version": "6.16.1",
"source": {
"type": "git",
"url": "https://github.com/vimeo/psalm.git",
- "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a"
+ "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0b040a91f280f071c1abcb1b77ce3822058725a",
- "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a",
+ "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac",
+ "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac",
"shasum": ""
},
"require": {
@@ -3177,7 +3199,7 @@
"netresearch/jsonmapper": "^5.0",
"nikic/php-parser": "^5.0.0",
"php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0",
- "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0",
+ "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0",
"spatie/array-to-xml": "^2.17.0 || ^3.0",
"symfony/console": "^6.0 || ^7.0 || ^8.0",
"symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0",
@@ -3258,20 +3280,20 @@
"issues": "https://github.com/vimeo/psalm/issues",
"source": "https://github.com/vimeo/psalm"
},
- "time": "2025-12-23T15:36:48+00:00"
+ "time": "2026-03-19T10:56:09+00:00"
},
{
"name": "webmozart/assert",
- "version": "2.1.1",
+ "version": "2.1.6",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
- "reference": "bdbabc199a7ba9965484e4725d66170e5711323b"
+ "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b",
- "reference": "bdbabc199a7ba9965484e4725d66170e5711323b",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8",
+ "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8",
"shasum": ""
},
"require": {
@@ -3318,9 +3340,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/2.1.1"
+ "source": "https://github.com/webmozarts/assert/tree/2.1.6"
},
- "time": "2026-01-08T11:28:40+00:00"
+ "time": "2026-02-27T10:28:38+00:00"
}
],
"packages-dev": [],
diff --git a/docs/index.rst b/docs/index.rst
index 864481a3f..b8b409159 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1319,9 +1319,7 @@ them during object creation to avoid this.
::
- use App\Entity\Post;
use App\Factory\PostFactory;
- use function Zenstruck\Foundry\Persistence\persistent_factory;
// disable ALL Doctrine event listeners during creation
$post = PostFactory::new()->withoutDoctrineEvents()->create(); // returns Post
@@ -1342,6 +1340,10 @@ If you'd like your factory to always disable Doctrine events, override its ``ini
;
}
+.. note::
+
+ ``withoutDoctrineEvents()`` cannot be used inside ``flush_after()``.
+
Array factories
~~~~~~~~~~~~~~~
diff --git a/src/Mongo/MongoPersistenceStrategy.php b/src/Mongo/MongoPersistenceStrategy.php
index f89e9ec12..f9237a794 100644
--- a/src/Mongo/MongoPersistenceStrategy.php
+++ b/src/Mongo/MongoPersistenceStrategy.php
@@ -11,6 +11,7 @@
namespace Zenstruck\Foundry\Mongo;
+use Doctrine\Common\EventManager;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\MappingException as MongoMappingException;
use Doctrine\Persistence\Mapping\MappingException;
@@ -94,4 +95,29 @@ public function getIdentifierValues(object $object): array
{
return $this->classMetadata($object::class)->getIdentifierValues($object);
}
+
+ public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed
+ {
+ $eventManager = $this->objectManagerFor($entityClass)->getEventManager();
+ $removed = [];
+
+ foreach ($eventManager->getAllListeners() as $eventName => $listeners) {
+ foreach ($listeners as $listener) {
+ if ([] === $disabledClasses || \in_array($listener::class, $disabledClasses, true)) {
+ $eventManager->removeEventListener([$eventName], $listener);
+ $removed[$eventName][] = $listener;
+ }
+ }
+ }
+
+ try {
+ return $callback();
+ } finally {
+ foreach ($removed as $eventName => $listeners) {
+ foreach ($listeners as $listener) {
+ $eventManager->addEventListener([$eventName], $listener);
+ }
+ }
+ }
+ }
}
diff --git a/src/ORM/AbstractORMPersistenceStrategy.php b/src/ORM/AbstractORMPersistenceStrategy.php
index bd303cad9..3395fb72f 100644
--- a/src/ORM/AbstractORMPersistenceStrategy.php
+++ b/src/ORM/AbstractORMPersistenceStrategy.php
@@ -11,9 +11,11 @@
namespace Zenstruck\Foundry\ORM;
+use Doctrine\Common\EventManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\MappingException as ORMMappingException;
use Doctrine\Persistence\Mapping\MappingException;
+use Doctrine\Persistence\ObjectManager;
use Zenstruck\Foundry\Persistence\PersistenceStrategy;
/**
@@ -118,4 +120,103 @@ function(mixed $value) use ($object) {
$identifiers
);
}
+
+ public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed
+ {
+ $om = $this->objectManagerFor($entityClass);
+
+ // Entity listeners first: getClassMetadata() triggers loadClassMetadata which registers
+ // #[AsEntityListener] listeners. Global listeners must still be active at that point.
+ $entityListenersBackup = $this->removeEntityListeners($om, $entityClass, $disabledClasses);
+ $globalListenersBackup = $this->removeGlobalListeners($om, $disabledClasses);
+
+ try {
+ return $callback();
+ } finally {
+ $this->restoreGlobalListeners($om, $globalListenersBackup);
+ $this->restoreEntityListeners($om, $entityClass, $entityListenersBackup);
+ }
+ }
+
+ /**
+ * @param list $disabledClasses
+ *
+ * @return array>
+ */
+ private function removeGlobalListeners(EntityManagerInterface $om, array $disabledClasses): array
+ {
+ $eventManager = $om->getEventManager();
+ $removed = [];
+
+ foreach ($eventManager->getAllListeners() as $eventName => $listeners) {
+ foreach ($listeners as $listener) {
+ if ([] === $disabledClasses || \in_array($listener::class, $disabledClasses, true)) {
+ $eventManager->removeEventListener([$eventName], $listener);
+ $removed[$eventName][] = $listener;
+ }
+ }
+ }
+
+ return $removed;
+ }
+
+ /**
+ * @param array> $removedListeners
+ */
+ private function restoreGlobalListeners(EntityManagerInterface $om, array $removedListeners): void
+ {
+ $eventManager = $om->getEventManager();
+
+ foreach ($removedListeners as $eventName => $listeners) {
+ foreach ($listeners as $listener) {
+ $eventManager->addEventListener([$eventName], $listener);
+ }
+ }
+ }
+
+ /**
+ * @param class-string $entityClass
+ * @param list $disabledClasses
+ *
+ * @return array>
+ */
+ private function removeEntityListeners(EntityManagerInterface $om, string $entityClass, array $disabledClasses): array
+ {
+ $metadata = $om->getClassMetadata($entityClass);
+ $original = $metadata->entityListeners;
+
+ if ([] === $original) {
+ return [];
+ }
+
+ if ([] === $disabledClasses) {
+ $metadata->entityListeners = [];
+
+ return $original;
+ }
+
+ $metadata->entityListeners = \array_filter(
+ \array_map(
+ static fn(array $listeners) => \array_values(\array_filter(
+ $listeners,
+ static fn(array $listener) => !\in_array($listener['class'], $disabledClasses, true),
+ )),
+ $original,
+ ),
+ static fn(array $listeners) => [] !== $listeners,
+ );
+
+ return $original;
+ }
+
+ /**
+ * @param class-string $entityClass
+ * @param array> $original
+ */
+ private function restoreEntityListeners(EntityManagerInterface $om, string $entityClass, array $original): void
+ {
+ if ([] !== $original) {
+ $om->getClassMetadata($entityClass)->entityListeners = $original;
+ }
+ }
}
diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php
index a23d42db7..bd64dbffc 100644
--- a/src/Persistence/PersistenceManager.php
+++ b/src/Persistence/PersistenceManager.php
@@ -11,8 +11,6 @@
namespace Zenstruck\Foundry\Persistence;
-use Doctrine\Common\EventManager;
-use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectRepository;
@@ -41,24 +39,6 @@ final class PersistenceManager
/** @var list */
private array $afterPersistCallbacks = [];
- /**
- * Event listener classes to keep disabled until the next real flush, accumulated across
- * multiple scheduleForInsert() calls inside a flush_after() context.
- * Keyed by spl_object_id of the ObjectManager.
- *
- * @var array|null>
- */
- private array $pendingEventClassesToDisable = [];
-
- /**
- * Entity-listener overrides to apply during the next real flush, accumulated across
- * multiple scheduleForInsert() calls inside a flush_after() context.
- * Keyed by spl_object_id of the ObjectManager.
- *
- * @var array|null}>>
- */
- private array $pendingEntityListenerOverrides = [];
-
/**
* @param iterable $strategies
*/
@@ -86,34 +66,19 @@ public function enablePersisting(): void
/**
* @template T of object
*
- * @param T $object
- * @param list|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled
+ * @param T $object
*
* @return T
*/
- public function save(object $object, ?array $disabledDoctrineEventClasses = null): object
+ public function save(object $object): object
{
if ($object instanceof Proxy) {
return $object->_save();
}
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
-
- // Disable for prePersist (fires during persist())
- // Note: disableEntityListeners must be called first, as it calls getClassMetadata() which
- // triggers the loadClassMetadata event to register #[AsEntityListener] listeners. If we
- // disabled global events first, that event would fire without the EntityListenerRegistry
- // handler, leaving entityListeners permanently empty in the metadata cache.
- $entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses);
- $removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses);
-
$om->persist($object);
-
- $this->restoreDoctrineEvents($om, $removedListeners);
- $this->restoreEntityListeners($om, $object::class, $entityListenerBackup);
-
- // Disable for postPersist / preUpdate (fire during flush())
- $this->flush($om, $disabledDoctrineEventClasses);
+ $this->flush($om);
$shouldFlush = $this->callPostPersistCallbacks();
@@ -129,41 +94,18 @@ public function save(object $object, ?array $disabledDoctrineEventClasses = null
*
* @param T $object
* @param list $afterPersistCallbacks
- * @param list|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled
*
* @return T
*/
- public function scheduleForInsert(object $object, array $afterPersistCallbacks = [], ?array $disabledDoctrineEventClasses = null): object
+ public function scheduleForInsert(object $object, array $afterPersistCallbacks = []): object
{
if ($object instanceof Proxy) {
$object = ProxyGenerator::unwrap($object);
}
$om = $this->strategyFor($object::class)->objectManagerFor($object::class);
-
- // Disable for prePersist (fires during persist())
- // Note: disableEntityListeners must be called first — same reason as in save().
- $entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses);
- $removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses);
-
$om->persist($object);
- $this->restoreDoctrineEvents($om, $removedListeners);
- $this->restoreEntityListeners($om, $object::class, $entityListenerBackup);
-
- // Accumulate for postPersist / preUpdate (fire during the deferred flush())
- if (null !== $disabledDoctrineEventClasses) {
- $omId = spl_object_id($om);
- $this->pendingEventClassesToDisable[$omId] = $this->mergeEventClasses(
- $this->pendingEventClassesToDisable[$omId] ?? null,
- $disabledDoctrineEventClasses,
- );
- $this->pendingEntityListenerOverrides[$omId][] = [
- 'entityClass' => $object::class,
- 'disabledClasses' => $disabledDoctrineEventClasses,
- ];
- }
-
$this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks];
return $object;
@@ -195,40 +137,10 @@ public function flushAfter(callable $callback): mixed
return $result;
}
- /**
- * @param list|null $eventClassesToDisable
- */
- public function flush(ObjectManager $om, ?array $eventClassesToDisable = null): void
+ public function flush(ObjectManager $om): void
{
- if (!$this->flush) {
- return;
- }
-
- $omId = spl_object_id($om);
-
- // Merge caller-supplied classes with anything accumulated from scheduleForInsert()
- /** @var list|null $pendingClasses */
- $pendingClasses = $this->pendingEventClassesToDisable[$omId] ?? null;
- $eventClassesToDisable = $this->mergeEventClasses($eventClassesToDisable, $pendingClasses);
- unset($this->pendingEventClassesToDisable[$omId]);
-
- $removedListeners = $this->disableDoctrineEvents($om, $eventClassesToDisable);
-
- // Apply entity-listener overrides accumulated from scheduleForInsert()
- $entityListenerBackups = [];
- foreach ($this->pendingEntityListenerOverrides[$omId] ?? [] as $override) {
- $entityListenerBackups[] = [
- 'entityClass' => $override['entityClass'],
- 'backup' => $this->disableEntityListeners($om, $override['entityClass'], $override['disabledClasses']),
- ];
- }
- unset($this->pendingEntityListenerOverrides[$omId]);
-
- $om->flush();
-
- $this->restoreDoctrineEvents($om, $removedListeners);
- foreach ($entityListenerBackups as ['entityClass' => $entityClass, 'backup' => $backup]) {
- $this->restoreEntityListeners($om, $entityClass, $backup);
+ if ($this->flush) {
+ $om->flush();
}
}
@@ -558,134 +470,21 @@ private function callPostPersistCallbacks(): bool
}
/**
- * Temporarily removes Doctrine event listeners from the EventManager.
- *
- * @param list|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed
+ * @template TCallback
*
- * @return array> map of eventName => removed listeners, for later restoration
- */
- private function disableDoctrineEvents(ObjectManager $om, ?array $eventClassesToDisable): array
- {
- if (null === $eventClassesToDisable || !method_exists($om, 'getEventManager')) {
- return [];
- }
-
- /** @var EventManager $eventManager */
- $eventManager = $om->getEventManager();
- $removed = [];
-
- foreach ($eventManager->getAllListeners() as $eventName => $listeners) {
- foreach ($listeners as $listener) {
- if ([] === $eventClassesToDisable || \in_array($listener::class, $eventClassesToDisable, true)) {
- $eventManager->removeEventListener([$eventName], $listener);
- $removed[$eventName][] = $listener;
- }
- }
- }
-
- return $removed;
- }
-
- /**
- * Re-adds Doctrine event listeners previously removed by disableDoctrineEvents().
+ * @param class-string $entityClass
+ * @param list $disabledClasses [] = all, [Foo::class] = specific
+ * @param callable():TCallback $callback
*
- * @param array> $removedListeners
+ * @return TCallback
*/
- private function restoreDoctrineEvents(ObjectManager $om, array $removedListeners): void
+ public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed
{
- if ([] === $removedListeners || !method_exists($om, 'getEventManager')) {
- return;
- }
-
- /** @var EventManager $eventManager */
- $eventManager = $om->getEventManager();
-
- foreach ($removedListeners as $eventName => $listeners) {
- foreach ($listeners as $listener) {
- $eventManager->addEventListener([$eventName], $listener);
- }
- }
- }
-
- /**
- * Temporarily removes Doctrine entity listeners by modifying the entity's ClassMetadata.
- *
- * @param class-string $entityClass
- * @param list|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed
- *
- * @return array> original entityListeners for later restoration
- */
- private function disableEntityListeners(ObjectManager $om, string $entityClass, ?array $eventClassesToDisable): array
- {
- if (null === $eventClassesToDisable || !$om instanceof EntityManagerInterface) {
- return [];
- }
-
- $metadata = $om->getClassMetadata($entityClass);
- $original = $metadata->entityListeners;
-
- if ([] === $original) {
- return [];
- }
-
- if ([] === $eventClassesToDisable) {
- $metadata->entityListeners = [];
- } else {
- $filtered = [];
- foreach ($original as $event => $listeners) {
- foreach ($listeners as $listener) {
- if (!\in_array($listener['class'], $eventClassesToDisable, true)) {
- $filtered[$event][] = $listener;
- }
- }
- }
- $metadata->entityListeners = $filtered;
- }
-
- return $original;
- }
-
- /**
- * Restores entity listeners previously removed by disableEntityListeners().
- *
- * @param class-string $entityClass
- * @param array> $original
- */
- private function restoreEntityListeners(ObjectManager $om, string $entityClass, array $original): void
- {
- if ([] === $original || !$om instanceof EntityManagerInterface) {
- return;
- }
-
- $om->getClassMetadata($entityClass)->entityListeners = $original;
- }
-
- /**
- * Merges two sets of event classes to disable, following these rules:
- * - null + anything = anything (null means "no disabling requested")
- * - [] + anything = [] ([] means "disable all", takes precedence)
- * - [A] + [B] = [A, B] (union of specific classes)
- *
- * @param list|null $a
- * @param list|null $b
- *
- * @return list|null
- */
- private function mergeEventClasses(?array $a, ?array $b): ?array
- {
- if (null === $a) {
- return $b;
- }
-
- if (null === $b) {
- return $a;
- }
-
- if ([] === $a || [] === $b) {
- return [];
+ if (!$this->flush) {
+ throw new \LogicException('withoutDoctrineEvents() cannot be used inside flush_after().');
}
- return \array_values(\array_unique([...$a, ...$b]));
+ return $this->strategyFor($entityClass)->withoutDoctrineEvents($entityClass, $disabledClasses, $callback);
}
/**
diff --git a/src/Persistence/PersistenceStrategy.php b/src/Persistence/PersistenceStrategy.php
index 1cb146bec..c7d680399 100644
--- a/src/Persistence/PersistenceStrategy.php
+++ b/src/Persistence/PersistenceStrategy.php
@@ -99,4 +99,17 @@ abstract public function embeddablePropertiesFor(object $object, string $owner):
abstract public function isEmbeddable(object $object): bool;
abstract public function isScheduledForInsert(object $object): bool;
+
+ /**
+ * Execute a callback with Doctrine event listeners temporarily disabled.
+ *
+ * @template T
+ *
+ * @param class-string $entityClass
+ * @param list $disabledClasses [] = disable all, [Foo::class] = disable specific
+ * @param callable():T $callback
+ *
+ * @return T
+ */
+ abstract public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed;
}
diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php
index fecbbccd1..34168be87 100644
--- a/src/Persistence/PersistentObjectFactory.php
+++ b/src/Persistence/PersistentObjectFactory.php
@@ -238,6 +238,24 @@ final public static function truncate(): void
* @return T
*/
public function create(callable|array $attributes = []): object
+ {
+ if (null !== $this->disabledDoctrineEventClasses) {
+ return Configuration::instance()->persistence()->withoutDoctrineEvents(
+ static::class(),
+ $this->disabledDoctrineEventClasses,
+ fn() => $this->doCreate($attributes),
+ );
+ }
+
+ return $this->doCreate($attributes);
+ }
+
+ /**
+ * @phpstan-param callable(int):Parameters|Parameters $attributes
+ *
+ * @return T
+ */
+ private function doCreate(callable|array $attributes): object
{
$configuration = Configuration::instance();
@@ -264,7 +282,7 @@ public function create(callable|array $attributes = []): object
throw new \LogicException('Persistence cannot be used in unit tests.');
}
- $configuration->persistence()->save($object, $this->disabledDoctrineEventClasses);
+ $configuration->persistence()->save($object);
return $object;
}
@@ -562,7 +580,7 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact
};
}
- Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks, $factoryUsed->disabledDoctrineEventClasses);
+ Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks);
},
self::PRIORITY_SCHEDULE_FOR_INSERT
)
diff --git a/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php
new file mode 100644
index 000000000..4cc4f1f33
--- /dev/null
+++ b/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php
@@ -0,0 +1,36 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents;
+
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\Tests\Fixture\Entity\ChildEntityForDoctrineEvents;
+
+/**
+ * @extends PersistentObjectFactory
+ */
+final class ChildEntityForDoctrineEventsFactory extends PersistentObjectFactory
+{
+ public static function class(): string
+ {
+ return ChildEntityForDoctrineEvents::class;
+ }
+
+ protected function defaults(): array
+ {
+ return [
+ 'name' => self::faker()->word(),
+ 'parent' => ParentEntityForDoctrineEventsFactory::new(),
+ ];
+ }
+}
diff --git a/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php b/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php
index ecff4be0a..25cf3f566 100644
--- a/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php
+++ b/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php
@@ -17,7 +17,9 @@
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
+use Zenstruck\Foundry\Tests\Fixture\Entity\ChildEntityForDoctrineEvents;
use Zenstruck\Foundry\Tests\Fixture\Entity\EntityForDoctrineEvents;
+use Zenstruck\Foundry\Tests\Fixture\Entity\ParentEntityForDoctrineEvents;
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
@@ -27,11 +29,12 @@ public function prePersist(PrePersistEventArgs $eventArgs): void
{
$object = $eventArgs->getObject();
- if (!$object instanceof EntityForDoctrineEvents) {
- return;
+ if ($object instanceof EntityForDoctrineEvents
+ || $object instanceof ParentEntityForDoctrineEvents
+ || $object instanceof ChildEntityForDoctrineEvents
+ ) {
+ $object->name .= ' (from Doctrine event)';
}
-
- $object->name .= ' (from Doctrine event)';
}
public function preUpdate(PreUpdateEventArgs $eventArgs): void
diff --git a/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php
new file mode 100644
index 000000000..2f99d0618
--- /dev/null
+++ b/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents;
+
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\Tests\Fixture\Document\DocumentForDoctrineEvents;
+
+/**
+ * @extends PersistentObjectFactory
+ */
+final class DocumentForDoctrineEventsFactory extends PersistentObjectFactory
+{
+ public static function class(): string
+ {
+ return DocumentForDoctrineEvents::class;
+ }
+
+ protected function defaults(): array
+ {
+ return [
+ 'name' => self::faker()->word(),
+ ];
+ }
+}
diff --git a/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php b/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php
new file mode 100644
index 000000000..cf839b62b
--- /dev/null
+++ b/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php
@@ -0,0 +1,34 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents;
+
+use Doctrine\Bundle\MongoDBBundle\Attribute\AsDocumentListener;
+use Doctrine\ODM\MongoDB\Event\LifecycleEventArgs;
+use Doctrine\ODM\MongoDB\Events;
+use Zenstruck\Foundry\Tests\Fixture\Document\DocumentForDoctrineEvents;
+
+#[AsDocumentListener(event: Events::prePersist)]
+final class MongoDoctrineEventsListener
+{
+ public function prePersist(LifecycleEventArgs $eventArgs): void
+ {
+ $object = $eventArgs->getDocument();
+
+ if (!$object instanceof DocumentForDoctrineEvents) {
+ return;
+ }
+
+ $object->name .= ' (from Mongo event)';
+ }
+}
diff --git a/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php
new file mode 100644
index 000000000..78dc0190c
--- /dev/null
+++ b/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php
@@ -0,0 +1,35 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents;
+
+use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
+use Zenstruck\Foundry\Tests\Fixture\Entity\ParentEntityForDoctrineEvents;
+
+/**
+ * @extends PersistentObjectFactory
+ */
+final class ParentEntityForDoctrineEventsFactory extends PersistentObjectFactory
+{
+ public static function class(): string
+ {
+ return ParentEntityForDoctrineEvents::class;
+ }
+
+ protected function defaults(): array
+ {
+ return [
+ 'name' => self::faker()->word(),
+ ];
+ }
+}
diff --git a/tests/Fixture/Document/DocumentForDoctrineEvents.php b/tests/Fixture/Document/DocumentForDoctrineEvents.php
new file mode 100644
index 000000000..b7597b7c8
--- /dev/null
+++ b/tests/Fixture/Document/DocumentForDoctrineEvents.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\Document;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB;
+
+#[MongoDB\Document]
+class DocumentForDoctrineEvents
+{
+ #[MongoDB\Id(type: 'int', strategy: 'INCREMENT')]
+ public ?int $id = null;
+
+ public function __construct(
+ #[MongoDB\Field(type: 'string')]
+ public string $name,
+ ) {
+ }
+}
diff --git a/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php b/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php
new file mode 100644
index 000000000..eb0f1c1f0
--- /dev/null
+++ b/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+use Zenstruck\Foundry\Tests\Fixture\Model\Base;
+
+#[ORM\Entity]
+class ChildEntityForDoctrineEvents extends Base
+{
+ public function __construct(
+ #[ORM\Column]
+ public string $name,
+ #[ORM\ManyToOne(targetEntity: ParentEntityForDoctrineEvents::class, inversedBy: 'children')]
+ public ?ParentEntityForDoctrineEvents $parent = null,
+ ) {
+ }
+}
diff --git a/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php b/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php
new file mode 100644
index 000000000..2acea8f55
--- /dev/null
+++ b/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php
@@ -0,0 +1,53 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Fixture\Entity;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+use Zenstruck\Foundry\Tests\Fixture\Model\Base;
+
+#[ORM\Entity]
+class ParentEntityForDoctrineEvents extends Base
+{
+ /** @var Collection */
+ #[ORM\OneToMany(targetEntity: ChildEntityForDoctrineEvents::class, mappedBy: 'parent', cascade: ['persist'])]
+ private Collection $children;
+
+ public function __construct(
+ #[ORM\Column]
+ public string $name,
+ ) {
+ $this->children = new ArrayCollection();
+ }
+
+ public function addChild(ChildEntityForDoctrineEvents $child): void
+ {
+ if (!$this->children->contains($child)) {
+ $this->children->add($child);
+ $child->parent = $this;
+ }
+ }
+
+ public function removeChild(ChildEntityForDoctrineEvents $child): void
+ {
+ $this->children->removeElement($child);
+ }
+
+ /** @return Collection */
+ public function getChildren(): Collection
+ {
+ return $this->children;
+ }
+}
diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php
index 876107937..c3753bb0c 100644
--- a/tests/Fixture/TestKernel.php
+++ b/tests/Fixture/TestKernel.php
@@ -22,10 +22,14 @@
use Zenstruck\Foundry\Tests\Fixture\App\Controller\HelloWorld;
use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\AsEntityListenerListener;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ChildEntityForDoctrineEventsFactory;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ParentEntityForDoctrineEventsFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DoctrineEventsSubscriber;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DocumentForDoctrineEventsFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityForDoctrineEventsFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithAsEntityListenerFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithOrmEntityListenerFactory;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\MongoDoctrineEventsListener;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\OrmEntityListener;
use Zenstruck\Foundry\Tests\Fixture\Events\FoundryEventListener;
use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory;
@@ -71,12 +75,22 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
$c->register(InMemoryContactRepository::class)->setAutowired(true)->setAutoconfigured(true);
$c->register(FoundryEventListener::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(DoctrineEventsSubscriber::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(EntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(OrmEntityListener::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(EntityWithOrmEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(AsEntityListenerListener::class)->setAutowired(true)->setAutoconfigured(true);
- $c->register(EntityWithAsEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true);
+
+ if (self::hasORM()) {
+ $c->register(DoctrineEventsSubscriber::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(EntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(OrmEntityListener::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(EntityWithOrmEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(AsEntityListenerListener::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(EntityWithAsEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(ParentEntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(ChildEntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ }
+
+ if (self::hasMongo()) {
+ $c->register(MongoDoctrineEventsListener::class)->setAutowired(true)->setAutoconfigured(true);
+ $c->register(DocumentForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true);
+ }
$c->register(DeleteGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments');
$c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments');
diff --git a/tests/Integration/Mongo/WithoutDoctrineEventsTest.php b/tests/Integration/Mongo/WithoutDoctrineEventsTest.php
new file mode 100644
index 000000000..6e8b3074f
--- /dev/null
+++ b/tests/Integration/Mongo/WithoutDoctrineEventsTest.php
@@ -0,0 +1,82 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Integration\Mongo;
+
+use PHPUnit\Framework\Attributes\Test;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Test\ResetDatabase;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DocumentForDoctrineEventsFactory;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\MongoDoctrineEventsListener;
+use Zenstruck\Foundry\Tests\Integration\RequiresMongo;
+
+use function Zenstruck\Foundry\Persistence\flush_after;
+
+final class WithoutDoctrineEventsTest extends KernelTestCase
+{
+ use Factories, RequiresMongo, ResetDatabase;
+
+ #[Test]
+ public function testMongoEventsAreCalledByDefault(): void
+ {
+ $document = DocumentForDoctrineEventsFactory::createOne(['name' => 'test']);
+
+ self::assertSame('test (from Mongo event)', $document->name);
+ }
+
+ #[Test]
+ public function testItCanDisableAllMongoEvents(): void
+ {
+ $document = DocumentForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents()
+ ->create(['name' => 'test']);
+
+ self::assertSame('test', $document->name);
+ }
+
+ #[Test]
+ public function testItCanDisableSpecificMongoEventListener(): void
+ {
+ $document = DocumentForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents(MongoDoctrineEventsListener::class)
+ ->create(['name' => 'test']);
+
+ self::assertSame('test', $document->name);
+ }
+
+ #[Test]
+ public function testMongoEventsAreRestoredAfterCreation(): void
+ {
+ DocumentForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents()
+ ->create(['name' => 'first']);
+
+ $document = DocumentForDoctrineEventsFactory::createOne(['name' => 'second']);
+
+ self::assertSame('second (from Mongo event)', $document->name);
+ }
+
+ #[Test]
+ public function testItThrowsWhenUsedInsideFlushAfter(): void
+ {
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('withoutDoctrineEvents() cannot be used inside flush_after().');
+
+ flush_after(static function(): void {
+ DocumentForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents()
+ ->create(['name' => 'test']);
+ });
+ }
+}
diff --git a/tests/Integration/WithoutDoctrineEventsTest.php b/tests/Integration/ORM/WithoutDoctrineEventsTest.php
similarity index 67%
rename from tests/Integration/WithoutDoctrineEventsTest.php
rename to tests/Integration/ORM/WithoutDoctrineEventsTest.php
index c410c72a0..ff5d576fe 100644
--- a/tests/Integration/WithoutDoctrineEventsTest.php
+++ b/tests/Integration/ORM/WithoutDoctrineEventsTest.php
@@ -11,18 +11,21 @@
* file that was distributed with this source code.
*/
-namespace Zenstruck\Foundry\Tests\Integration;
+namespace Zenstruck\Foundry\Tests\Integration\ORM;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\AsEntityListenerListener;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ChildEntityForDoctrineEventsFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DoctrineEventsSubscriber;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityForDoctrineEventsFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithAsEntityListenerFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithOrmEntityListenerFactory;
use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\OrmEntityListener;
+use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ParentEntityForDoctrineEventsFactory;
+use Zenstruck\Foundry\Tests\Integration\RequiresORM;
use function Zenstruck\Foundry\Persistence\flush_after;
@@ -74,42 +77,16 @@ public function testDoctrineEventsAreRestoredAfterCreation(): void
// --- flush_after() ---
#[Test]
- public function testItCanDisableAllDoctrineEventsInsideFlushAfter(): void
+ public function testItThrowsWhenUsedInsideFlushAfter(): void
{
- $entity = flush_after(static function(): mixed {
- return EntityForDoctrineEventsFactory::new()
- ->withoutDoctrineEvents()
- ->create(['name' => 'test']);
- });
-
- self::assertSame('test', $entity->name);
- }
-
- #[Test]
- public function testItCanDisableSpecificListenerInsideFlushAfter(): void
- {
- $entity = flush_after(static function(): mixed {
- return EntityForDoctrineEventsFactory::new()
- ->withoutDoctrineEvents(DoctrineEventsSubscriber::class)
- ->create(['name' => 'test']);
- });
-
- self::assertSame('test', $entity->name);
- }
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('withoutDoctrineEvents() cannot be used inside flush_after().');
- #[Test]
- public function testDoctrineEventsAreRestoredAfterFlushAfter(): void
- {
flush_after(static function(): void {
EntityForDoctrineEventsFactory::new()
->withoutDoctrineEvents()
- ->create(['name' => 'first']);
+ ->create(['name' => 'test']);
});
-
- // Events must be restored for subsequent factories
- $entity = EntityForDoctrineEventsFactory::createOne(['name' => 'second']);
-
- self::assertSame('second (from Doctrine event)', $entity->name);
}
// --- #[ORM\EntityListeners] ---
@@ -195,4 +172,63 @@ public function testAsEntityListenerIsRestoredAfterCreation(): void
self::assertSame('second (from AsEntityListener)', $entity->name);
}
+
+ // --- Relations: ManyToOne (child → parent) ---
+
+ #[Test]
+ public function testEventsAreCalledByDefaultOnChildAndParent(): void
+ {
+ $child = ChildEntityForDoctrineEventsFactory::createOne(['name' => 'child']);
+
+ self::assertSame('child (from Doctrine event)', $child->name);
+ self::assertNotNull($child->parent);
+ self::assertStringEndsWith('(from Doctrine event)', $child->parent->name);
+ }
+
+ #[Test]
+ public function testWithoutDoctrineEventsPropagatesFromChildToParent(): void
+ {
+ $child = ChildEntityForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents()
+ ->create(['name' => 'child']);
+
+ self::assertSame('child', $child->name);
+ self::assertNotNull($child->parent);
+ self::assertStringNotContainsString('(from Doctrine event)', $child->parent->name);
+ }
+
+ // --- Relations: OneToMany (parent → children) ---
+
+ #[Test]
+ public function testEventsAreCalledByDefaultOnParentAndChildren(): void
+ {
+ $parent = ParentEntityForDoctrineEventsFactory::createOne([
+ 'name' => 'parent',
+ 'children' => ChildEntityForDoctrineEventsFactory::new()->many(2),
+ ]);
+
+ self::assertSame('parent (from Doctrine event)', $parent->name);
+ self::assertCount(2, $parent->getChildren());
+
+ foreach ($parent->getChildren() as $child) {
+ self::assertStringEndsWith('(from Doctrine event)', $child->name);
+ }
+ }
+
+ #[Test]
+ public function testWithoutDoctrineEventsPropagatesFromParentToChildren(): void
+ {
+ $parent = ParentEntityForDoctrineEventsFactory::new()
+ ->withoutDoctrineEvents()
+ ->create([
+ 'name' => 'parent',
+ 'children' => ChildEntityForDoctrineEventsFactory::new()->many(2),
+ ]);
+
+ self::assertSame('parent', $parent->name);
+
+ foreach ($parent->getChildren() as $child) {
+ self::assertStringNotContainsString('(from Doctrine event)', $child->name);
+ }
+ }
}