diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml
index fb117d213..507ce5eb6 100644
--- a/.roave-backward-compatibility-check.xml
+++ b/.roave-backward-compatibility-check.xml
@@ -7,5 +7,6 @@
#\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\PluginEntryPointInterface" could not be found in the located source##\[BC\] SKIPPED: Roave\\BetterReflection\\Reflection\\ReflectionClass "Psalm\\Plugin\\EventHandler\\AfterMethodCallAnalysisInterface" could not be found in the located source##(.*)Zenstruck\\Foundry\\Utils\\Rector(.*)#
+ #(.*)initializeInternal(.*)#
diff --git a/composer.json b/composer.json
index 51901d40f..aa9e2bb58 100644
--- a/composer.json
+++ b/composer.json
@@ -41,6 +41,7 @@
"symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dotenv": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/maker-bundle": "^1.55",
"symfony/phpunit-bridge": "^6.4|^7.0|^8.0",
@@ -84,6 +85,7 @@
},
"conflict": {
"doctrine/persistence": "<2.0",
+ "symfony/event-dispatcher": "<6.4",
"symfony/framework-bundle": "<6.4"
},
"extra": {
diff --git a/config/persistence.php b/config/persistence.php
index c47bd9534..150332a98 100644
--- a/config/persistence.php
+++ b/config/persistence.php
@@ -15,8 +15,9 @@
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent;
use Zenstruck\Foundry\Command\LoadFixturesCommand;
+use Zenstruck\Foundry\Persistence\Event\AfterPersist;
use Zenstruck\Foundry\Persistence\PersistenceManager;
-use Zenstruck\Foundry\Persistence\Proxy\PersistedObjectsTracker;
+use Zenstruck\Foundry\Persistence\PersistedObjectsTracker;
use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager;
return static function(ContainerConfigurator $container): void {
@@ -47,6 +48,7 @@
->tag('kernel.event_listener', ['event' => TerminateEvent::class, 'method' => 'refresh'])
->tag('kernel.event_listener', ['event' => ConsoleTerminateEvent::class, 'method' => 'refresh'])
->tag('kernel.event_listener', ['event' => WorkerMessageHandledEvent::class, 'method' => 'refresh']) // @phpstan-ignore class.notFound
+ ->tag('foundry.hook', ['class' => null, 'method' => 'afterPersistHook', 'event' => AfterPersist::class])
;
}
};
diff --git a/config/services.php b/config/services.php
index acc4a128d..05e9e5849 100644
--- a/config/services.php
+++ b/config/services.php
@@ -46,6 +46,7 @@
service('.zenstruck_foundry.in_memory.repository_registry'),
service('.foundry.persistence.objects_tracker')->nullOnInvalid(),
param('zenstruck_foundry.enable_auto_refresh_with_lazy_objects'),
+ service('event_dispatcher')->nullOnInvalid(),
])
->public()
;
diff --git a/docs/index.rst b/docs/index.rst
index a4befee73..4c147067d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -659,6 +659,58 @@ You can also add hooks directly in your factory class:
Read `Initialization`_ to learn more about the ``initialize()`` method.
+Hooks as service / global hooks
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+For a better control of your hooks, you can define them as services, allowing to leverage dependency injection and
+to create hooks globally:
+
+::
+
+ use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+ use Zenstruck\Foundry\Object\Event\AfterInstantiate;
+ use Zenstruck\Foundry\Object\Event\BeforeInstantiate;
+ use Zenstruck\Foundry\Persistence\Event\AfterPersist;
+
+ final class FoundryHook
+ {
+ #[AsFoundryHook(Post::class)]
+ public function beforeInstantiate(BeforeInstantiate $event): void
+ {
+ // do something before the post is instantiated:
+ // $event->parameters is what will be used to instantiate the object, manipulate as required
+ // $event->objectClass is the class of the object being instantiated
+ // $event->factory is the factory instance which creates the object
+ }
+
+ #[AsFoundryHook(Post::class)]
+ public function afterInstantiate(AfterInstantiate $event): void
+ {
+ // $event->object is the instantiated Post object
+ // $event->parameters contains the attributes used to instantiate the object and any extras
+ // $event->factory is the factory instance which creates the object
+ }
+
+ #[AsFoundryHook(Post::class)]
+ public function afterPersist(AfterPersist $event): void
+ {
+ // this event is only called if the object was persisted
+ // $event->object is the persisted Post object
+ // $event->parameters contains the attributes used to instantiate the object and any extras
+ // $event->factory is the factory instance which creates the object
+ }
+
+ #[AsFoundryHook]
+ public function afterInstantiateGlobal(AfterInstantiate $event): void
+ {
+ // Omitting class defines a "global" hook which will be called for all objects
+ }
+ }
+
+.. versionadded:: 2.4
+
+ The ``#[AsFoundryHook]`` attribute was added in Foundry 2.4.
+
Initialization
~~~~~~~~~~~~~~
diff --git a/src/Attribute/AsFoundryHook.php b/src/Attribute/AsFoundryHook.php
new file mode 100644
index 000000000..68ff14e16
--- /dev/null
+++ b/src/Attribute/AsFoundryHook.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Attribute;
+
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+
+#[\Attribute(\Attribute::TARGET_METHOD)]
+final class AsFoundryHook extends AsEventListener
+{
+ public function __construct(
+ /** @var class-string */
+ public readonly ?string $objectClass = null,
+ int $priority = 0,
+ ) {
+ parent::__construct(priority: $priority);
+ }
+}
diff --git a/src/Configuration.php b/src/Configuration.php
index 818941d30..211ca2319 100644
--- a/src/Configuration.php
+++ b/src/Configuration.php
@@ -12,6 +12,7 @@
namespace Zenstruck\Foundry;
use Faker;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Zenstruck\Foundry\Exception\FactoriesTraitNotUsed;
use Zenstruck\Foundry\Exception\FoundryNotBooted;
use Zenstruck\Foundry\Exception\PersistenceDisabled;
@@ -19,7 +20,7 @@
use Zenstruck\Foundry\InMemory\CannotEnableInMemory;
use Zenstruck\Foundry\InMemory\InMemoryRepositoryRegistry;
use Zenstruck\Foundry\Persistence\PersistenceManager;
-use Zenstruck\Foundry\Persistence\Proxy\PersistedObjectsTracker;
+use Zenstruck\Foundry\Persistence\PersistedObjectsTracker;
/**
* @author Kevin Bond
@@ -63,6 +64,7 @@ public function __construct(
public readonly ?InMemoryRepositoryRegistry $inMemoryRepositoryRegistry = null,
public readonly ?PersistedObjectsTracker $persistedObjectsTracker = null,
private readonly bool $enableAutoRefreshWithLazyObjects = false,
+ private readonly ?EventDispatcherInterface $eventDispatcher = null,
) {
if (null === self::$instance) {
$this->faker->seed(self::fakerSeed($forcedFakerSeed));
@@ -106,6 +108,16 @@ public function assertPersistenceEnabled(): void
}
}
+ public function hasEventDispatcher(): bool
+ {
+ return (bool) $this->eventDispatcher;
+ }
+
+ public function eventDispatcher(): EventDispatcherInterface
+ {
+ return $this->eventDispatcher ?? throw new \RuntimeException('No event dispatcher configured.');
+ }
+
public function inADataProvider(): bool
{
return $this->bootedForDataProvider;
diff --git a/src/Object/Event/AfterInstantiate.php b/src/Object/Event/AfterInstantiate.php
new file mode 100644
index 000000000..356256531
--- /dev/null
+++ b/src/Object/Event/AfterInstantiate.php
@@ -0,0 +1,43 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Object\Event;
+
+use Zenstruck\Foundry\Factory;
+use Zenstruck\Foundry\ObjectFactory;
+
+/**
+ * @author Nicolas PHILIPPE
+ *
+ * @template T of object
+ * @implements Event
+ *
+ * @phpstan-import-type Parameters from Factory
+ */
+final class AfterInstantiate implements Event
+{
+ public function __construct(
+ /** @var T */
+ public readonly object $object,
+ /** @phpstan-var Parameters */
+ public readonly array $parameters,
+ /** @var ObjectFactory */
+ public readonly ObjectFactory $factory,
+ ) {
+ }
+
+ public function objectClassName(): string
+ {
+ return $this->object::class;
+ }
+}
diff --git a/src/Object/Event/BeforeInstantiate.php b/src/Object/Event/BeforeInstantiate.php
new file mode 100644
index 000000000..a79174056
--- /dev/null
+++ b/src/Object/Event/BeforeInstantiate.php
@@ -0,0 +1,43 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Object\Event;
+
+use Zenstruck\Foundry\Factory;
+use Zenstruck\Foundry\ObjectFactory;
+
+/**
+ * @author Nicolas PHILIPPE
+ *
+ * @template T of object
+ * @implements Event
+ *
+ * @phpstan-import-type Parameters from Factory
+ */
+final class BeforeInstantiate implements Event
+{
+ public function __construct(
+ /** @phpstan-var Parameters */
+ public array $parameters,
+ /** @var class-string */
+ public readonly string $objectClass,
+ /** @var ObjectFactory */
+ public readonly ObjectFactory $factory,
+ ) {
+ }
+
+ public function objectClassName(): string
+ {
+ return $this->objectClass;
+ }
+}
diff --git a/src/Object/Event/Event.php b/src/Object/Event/Event.php
new file mode 100644
index 000000000..95382cb25
--- /dev/null
+++ b/src/Object/Event/Event.php
@@ -0,0 +1,25 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Object\Event;
+
+/**
+ * @template T of object
+ */
+interface Event
+{
+ /**
+ * @return class-string
+ */
+ public function objectClassName(): string;
+}
diff --git a/src/Object/Event/HookListenerFilter.php b/src/Object/Event/HookListenerFilter.php
new file mode 100644
index 000000000..12e28e0eb
--- /dev/null
+++ b/src/Object/Event/HookListenerFilter.php
@@ -0,0 +1,45 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Object\Event;
+
+final class HookListenerFilter
+{
+ /** @var \Closure(Event