Skip to content

Commit fd433b1

Browse files
authored
[feature] allow factories to be defined as services (#53)
1 parent ddb2f3d commit fd433b1

File tree

13 files changed

+276
-8
lines changed

13 files changed

+276
-8
lines changed

README.md

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ to load fixtures or inside your tests, [where it has even more features](#using-
3535
8. [Instantiation](#instantiation)
3636
9. [Immutable](#immutable)
3737
10. [Doctrine Relationships](#doctrine-relationships)
38-
11. [Anonymous Factories](#anonymous-factories)
39-
12. [Without Persisting](#without-persisting)
38+
11. [Factories as Services](#factories-as-services)
39+
12. [Anonymous Factories](#anonymous-factories)
40+
13. [Without Persisting](#without-persisting)
4041
4. [Using with DoctrineFixturesBundle](#using-with-doctrinefixturesbundle)
4142
5. [Using in your Tests](#using-in-your-tests)
4243
1. [Enable Foundry in your TestCase](#enable-foundry-in-your-testcase)
@@ -199,6 +200,13 @@ use Zenstruck\Foundry\Proxy;
199200
*/
200201
final class PostFactory extends ModelFactory
201202
{
203+
public function __construct()
204+
{
205+
parent::__construct();
206+
207+
// TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services)
208+
}
209+
202210
protected function getDefaults(): array
203211
{
204212
return [
@@ -277,6 +285,9 @@ $posts = PostFactory::randomSet(4); // array containing 4 "Post|Proxy" objects
277285
$posts = PostFactory::randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects
278286
```
279287

288+
**WARNING**: Never instantiate your `ModelFactory` with the constructor (ie `new PostFactory()`). This will
289+
cause the factory to not be instantiated properly. Always instantiate with `PostFactory::new()`.
290+
280291
### Reusable Model Factory "States"
281292

282293
You can add any methods you want to your model factories (ie static methods that create an object in a certain way) but
@@ -680,6 +691,71 @@ PostFactory::new()->many(3)->create(['tags' => TagFactory::new()->many(3)]);
680691
PostFactory::new()->many(3)->create(['tags' => TagFactory::new()->many(0, 3)]);
681692
```
682693

694+
### Factories as Services
695+
696+
If your factories require dependencies, you can define them as a service. The following example demonstrates a very
697+
common use-case: encoding a password with the `UserPasswordEncoderInterface` service.
698+
699+
```php
700+
// src/Factory/UserFactory.php
701+
702+
namespace App\Story;
703+
704+
use App\Entity\User;
705+
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
706+
use Zenstruck\Foundry\ModelFactory;
707+
708+
final class UserFactory extends ModelFactory
709+
{
710+
private $passwordEncoder;
711+
712+
public function __construct(UserPasswordEncoderInterface $passwordEncoder)
713+
{
714+
parent::__construct();
715+
716+
$this->passwordEncoder = $passwordEncoder;
717+
}
718+
719+
protected function getDefaults(): array
720+
{
721+
return [
722+
'email' => self::faker()->unique()->safeEmail,
723+
'password' => '1234',
724+
];
725+
}
726+
727+
protected function initialize(): self
728+
{
729+
return $this
730+
->afterInstantiate(function(User $user) {
731+
$user->setPassword($this->passwordEncoder->encodePassword($user, $user->getPassword()));
732+
})
733+
;
734+
}
735+
736+
protected static function getClass(): string
737+
{
738+
return User::class;
739+
}
740+
}
741+
```
742+
743+
If using a standard Symfony Flex app, this will be autowired/autoconfigured. If not, register the service and tag
744+
with `foundry.factory`.
745+
746+
Use the factory as normal:
747+
748+
```php
749+
UserFactory::new()->create(['password' => 'mypass'])->getPassword(); // "mypass" encoded
750+
UserFactory::new()->create()->getPassword(); // "1234" encoded (because "1234" is set as the default password)
751+
```
752+
753+
**NOTES**:
754+
1. The provided bundle is required for factories as services.
755+
2. If using `make:factory --test`, factories will be created in the `tests/Factory` directory which is not
756+
autowired/autoconfigured in a standard Symfony Flex app. You will have to manually register these as
757+
services.
758+
683759
### Anonymous Factories
684760

685761
Foundry can be used to create factories for entities that you don't have model factories for:
@@ -1172,6 +1248,27 @@ these tests to be unnecessarily slow. You can improve the speed by reducing the
11721248
memory_cost: 10 # Lowest possible value for argon
11731249
```
11741250

1251+
3. Pre-encode user passwords with a known value via `bin/console security:encode-password` and set this in
1252+
`ModelFactory::getDefaults()`. Add the known value as a `const` on your factory:
1253+
1254+
```php
1255+
class UserFactory extends ModelFactory
1256+
{
1257+
public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below
1258+
1259+
protected function getDefaults(): array
1260+
{
1261+
return [
1262+
// ...
1263+
'password' => '$argon2id$v=19$m=65536,t=4,p=1$pLFF3D2gnvDmxMuuqH4BrA$3vKfv0cw+6EaNspq9btVAYc+jCOqrmWRstInB2fRPeQ',
1264+
];
1265+
}
1266+
}
1267+
```
1268+
1269+
Now, in your tests, when you need access to the unencoded password for a user created with `UserFactory`, use
1270+
`UserFactory::DEFAULT_PASSWORD`.
1271+
11751272
### Using without the Bundle
11761273

11771274
The provided bundle is not strictly required to use Foundry for tests. You can have all your factories, stories, and
@@ -1262,7 +1359,7 @@ PostStory::load(); // does nothing - already loaded
12621359

12631360
### Stories as Services
12641361

1265-
If you stories require dependencies, you can define them as a service:
1362+
If your stories require dependencies, you can define them as a service:
12661363

12671364
```php
12681365
// src/Story/PostStory.php

src/Bundle/DependencyInjection/ZenstruckFoundryExtension.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
88
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
99
use Zenstruck\Foundry\Configuration;
10+
use Zenstruck\Foundry\ModelFactory;
1011
use Zenstruck\Foundry\Story;
1112

1213
/**
@@ -24,6 +25,10 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container
2425
->addTag('foundry.story')
2526
;
2627

28+
$container->registerForAutoconfiguration(ModelFactory::class)
29+
->addTag('foundry.factory')
30+
;
31+
2732
$this->configureFaker($mergedConfig['faker'], $container);
2833
$this->configureDefaultInstantiator($mergedConfig['instantiator'], $container);
2934

src/Bundle/Resources/config/services.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<service id="Zenstruck\Foundry\Configuration" public="true">
1414
<argument type="service" id="doctrine" />
1515
<argument type="service" id="Zenstruck\Foundry\StoryManager" />
16+
<argument type="service" id="Zenstruck\Foundry\ModelFactoryManager" />
1617
<call method="setInstantiator">
1718
<argument type="service" id="zenstruck_foundry.default_instantiator" />
1819
</call>
@@ -25,6 +26,10 @@
2526
<argument type="tagged_iterator" tag="foundry.story" />
2627
</service>
2728

29+
<service id="Zenstruck\Foundry\ModelFactoryManager">
30+
<argument type="tagged_iterator" tag="foundry.factory" />
31+
</service>
32+
2833
<service id="Zenstruck\Foundry\Bundle\Maker\MakeFactory">
2934
<argument type="service" id="doctrine" />
3035
<tag name="maker.command" />

src/Bundle/Resources/skeleton/Factory.tpl.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
*/
2222
final class <?= $class_name ?> extends ModelFactory
2323
{
24+
public function __construct()
25+
{
26+
parent::__construct();
27+
28+
// TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services)
29+
}
30+
2431
protected function getDefaults(): array
2532
{
2633
return [

src/Configuration.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ final class Configuration
1919
/** @var StoryManager */
2020
private $stories;
2121

22+
/** @var ModelFactoryManager */
23+
private $factories;
24+
2225
/** @var Faker\Generator */
2326
private $faker;
2427

@@ -28,10 +31,11 @@ final class Configuration
2831
/** @var bool */
2932
private $defaultProxyAutoRefresh = false;
3033

31-
public function __construct(ManagerRegistry $managerRegistry, StoryManager $storyManager)
34+
public function __construct(ManagerRegistry $managerRegistry, StoryManager $storyManager, ModelFactoryManager $factories)
3235
{
3336
$this->managerRegistry = $managerRegistry;
3437
$this->stories = $storyManager;
38+
$this->factories = $factories;
3539
$this->faker = Faker\Factory::create();
3640
$this->instantiator = new Instantiator();
3741
}
@@ -41,6 +45,11 @@ public function stories(): StoryManager
4145
return $this->stories;
4246
}
4347

48+
public function factories(): ModelFactoryManager
49+
{
50+
return $this->factories;
51+
}
52+
4453
public function faker(): Faker\Generator
4554
{
4655
return $this->faker;

src/ModelFactory.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
abstract class ModelFactory extends Factory
99
{
10-
private function __construct()
10+
public function __construct()
1111
{
1212
parent::__construct(static::getClass());
1313
}
@@ -23,7 +23,7 @@ final public static function new($defaultAttributes = [], string ...$states): se
2323
$defaultAttributes = [];
2424
}
2525

26-
$factory = new static();
26+
$factory = self::configuration()->factories()->create(static::class);
2727
$factory = $factory
2828
->withAttributes([$factory, 'getDefaults'])
2929
->withAttributes($defaultAttributes)

src/ModelFactoryManager.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry;
4+
5+
/**
6+
* @internal
7+
*
8+
* @author Kevin Bond <kevinbond@gmail.com>
9+
*/
10+
final class ModelFactoryManager
11+
{
12+
private $factories;
13+
14+
/**
15+
* @param ModelFactory[] $factories
16+
*/
17+
public function __construct(iterable $factories)
18+
{
19+
$this->factories = $factories;
20+
}
21+
22+
public function create(string $class): ModelFactory
23+
{
24+
foreach ($this->factories as $factory) {
25+
if ($class === \get_class($factory)) {
26+
return $factory;
27+
}
28+
}
29+
30+
return new $class();
31+
}
32+
}

src/Test/TestState.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Psr\Container\NotFoundExceptionInterface;
88
use Zenstruck\Foundry\Configuration;
99
use Zenstruck\Foundry\Factory;
10+
use Zenstruck\Foundry\ModelFactoryManager;
1011
use Zenstruck\Foundry\StoryManager;
1112

1213
/**
@@ -87,7 +88,7 @@ public static function bootFromContainer(ContainerInterface $container): Configu
8788
}
8889

8990
try {
90-
return self::bootFactory(new Configuration($container->get('doctrine'), new StoryManager([])));
91+
return self::bootFactory(new Configuration($container->get('doctrine'), new StoryManager([]), new ModelFactoryManager([])));
9192
} catch (NotFoundExceptionInterface $e) {
9293
throw new \LogicException('Could not boot Foundry, is the DoctrineBundle installed/configured?', 0, $e);
9394
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Zenstruck\Foundry\Tests\Fixtures\Factories;
4+
5+
use Zenstruck\Foundry\ModelFactory;
6+
use Zenstruck\Foundry\Tests\Fixtures\Entity\Category;
7+
use Zenstruck\Foundry\Tests\Fixtures\Service;
8+
9+
/**
10+
* @author Kevin Bond <kevinbond@gmail.com>
11+
*/
12+
final class CategoryServiceFactory extends ModelFactory
13+
{
14+
private $service;
15+
16+
public function __construct(Service $service)
17+
{
18+
parent::__construct();
19+
20+
$this->service = $service;
21+
}
22+
23+
protected static function getClass(): string
24+
{
25+
return Category::class;
26+
}
27+
28+
protected function getDefaults(): array
29+
{
30+
return ['name' => $this->service->name];
31+
}
32+
}

tests/Fixtures/Kernel.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use Symfony\Component\DependencyInjection\ContainerBuilder;
1313
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
1414
use Symfony\Component\Routing\RouteCollectionBuilder;
15+
use Zenstruck\Foundry\Tests\Fixtures\Factories\CategoryFactory;
16+
use Zenstruck\Foundry\Tests\Fixtures\Factories\CategoryServiceFactory;
1517
use Zenstruck\Foundry\Tests\Fixtures\Stories\ServiceStory;
1618
use Zenstruck\Foundry\ZenstruckFoundryBundle;
1719

@@ -56,6 +58,14 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load
5658
->setAutoconfigured(true)
5759
->setAutowired(true)
5860
;
61+
$c->register(CategoryFactory::class)
62+
->setAutoconfigured(true)
63+
->setAutowired(true)
64+
;
65+
$c->register(CategoryServiceFactory::class)
66+
->setAutoconfigured(true)
67+
->setAutowired(true)
68+
;
5969

6070
$c->loadFromExtension('framework', [
6171
'secret' => 'S3CRET',

0 commit comments

Comments
 (0)