How to use the entity from the app (/src) scope in your independent standalone bundles for your Doctrine & Symfony projects

Filip Horvat
6 min readFeb 5, 2024

--

Introduction

Let’s assume that you have an entity in the app scope of the application (‘src/Entity’ folder), and you want to use that entity in your bundle. However, you also want to make your bundle independent and standalone, which means it should not rely on any code from the app scope.

This story will show you how you can achieve that.

Here is an example that demonstrates how you can achieve this. In the app scope, we have a User entity (app\Entity\User.php), and we will create a Log bundle. This bundle will contain an Log entity for logs, and that entity should have a relation to the User to identify the user who created the log entry.

For the sake of simplicity, our Log entity will have only three columns: id, text, and user. The datetime of log creation time is probably missing, but I want to make an example as simple as possible, which is why it’s not included.

We will create a bundle inside this folder, ‘Bundles\LogBundle’. The bundle will not be created as a Composer package because I want to keep the example as simple as possible. However, it will be independent, and you will be able to export it as a Composer package later without any issues.

You will need to include the bundle via Composer:

"autoload": {
"psr-4": {
"App\\": "src/",
"Bundles\\": "Bundles/"
}
},

Next, create a basic configuration for the bundle and include it in the ‘bundles.php’ file, but that is beyond the scope of this story.

Here are the entities that we have:

app\Entity\User.php
Bundles\LogBundle\Entity\Log.php

Bundle example and idea

Here is our initial Log entity inside the Log bundle:

<?php

namespace Bundles\LogBundle\Entity;

use App\Entity\User;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Log
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $text = null;

#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'logs')]
private ?User $user = null;

public function getId(): ?int
{
return $this->id;
}

public function getText(): ?string
{
return $this->text;
}

public function setText(?string $text): void
{
$this->text = $text;
}

public function getUser(): ?User
{
return $this->user;
}

public function setUser(?User $user): void
{
$this->user = $user;
}
}

Here is our initial User entity within the app scope:

<?php

namespace App\Entity;

use Bundles\LogBundle\Entity\Log;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

#[ORM\OneToMany(mappedBy: 'user', targetEntity: Log::class)]
private Collection $logs;

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function setName(?string $name): void
{
$this->name = $name;
}

public function getLogs(): Collection
{
return $this->logs;
}

public function setLogs(Collection $logs): void
{
$this->logs = $logs;
}
}

You can see that we are referencing ‘App’ inside the bundle, and we want to avoid this. The goal is to make the bundle independent, reusable, and standalone.

Now, I will first explain the general idea and purpose of this demo bundle so that you can better understand what we aim to achieve. After that, I will demonstrate how we can make the bundle independent, reusable, and standalone.

Here is the code where you can see how logs are stored:

$user = $this->entityManager->getRepository(User::class)->find(1);
$user2 = $this->entityManager->getRepository(User::class)->find(2);

$log = new Log();
$log->setText('I looked at the order #16');
$log->setUser($user);

$log2 = new Log();
$log2->setText('I looked at the order #17');
$log2->setUser($user);

$log3 = new Log();
$log3->setText('I looked at the order #18');
$log3->setUser($user2);


$this->entityManager->persist($log);
$this->entityManager->persist($log2);
$this->entityManager->persist($log3);

We are storing in the log that the first user looked at orders $16 and #17, and we are also storing that the second user looked at order $18.

Of course, in the Log entity, we are missing the datetime when the user looked. The actual implementation should involve calling this code from an event listener, but this is just a dummy simple example.

Convert bundle to be independent

To make the bundle independent, we will use a Doctrine utility called ‘ResolveTargetEntityListener.’ Here is the documentation for it:

https://www.doctrine-project.org/projects/doctrine-orm/en/2.17/cookbook/resolve-target-entity-listener.html

Steps to make a bundle independent:

1. First, we will create a LogUserInterface inside the Log Bundle:

<?php

namespace Bundles\LogBundle\Entity;

interface LogUserInterface
{
public function getId(): ?int;
}

2. Next, our User entity from the app scope will implement that interface:

<?php

namespace App\Entity;

//...

#[ORM\Entity]
class User implements LogUserInterface
{

3. The relation to the User from the Log entity inside the bundle will no longer be directly tied to the User entity; instead, it will have a relation to the LogUserInterface that we created:

#[ORM\Entity]
class Log
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $text = null;

#[ORM\ManyToOne(targetEntity: LogUserInterface::class, inversedBy: 'logs')]
private ?LogUserInterface $user = null;

4. The final step is to add the doctrine configuration mapping so that Doctrine will know which entity class is used for that interface:

doctrine:
orm:
resolve_target_entities:
Bundles\LogBundle\Entity\LogUserInterface: App\Entity\User

You can add it to the global Doctrine configuration:

config/packages/doctrine.yaml

or you can add it to the configuration for your bundle for better organization:

config/packages/bundle_log.yaml

Your code now works as before, but there is no reference to ‘app’ classes from your bundle. Your bundle is now 100% independent, can be exported as a Composer package, and reused in all your projects.

Test independent bundle

The final step is to guide you on how to test your independent Log bundle.

Of course, your test should also be independent, meaning it should not depend on your code from the ‘app’ context. This implies that you cannot use ‘App\Entity\User’ in your test case. Therefore, we will create a new entity Bundles\LogBundle\tests\Util\Entity\User that will be loaded only during testing:

<?php

namespace Bundles\LogBundle\tests\Util\Entity;

use Bundles\LogBundle\Entity\Log;
use Bundles\LogBundle\Entity\LogUserInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'test_user')]
class User implements LogUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\Column(length: 255)]
private ?string $name = null;

#[ORM\OneToMany(mappedBy: 'user', targetEntity: Log::class)]
private Collection $logs;

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function setName(?string $name): void
{
$this->name = $name;
}

public function getLogs(): Collection
{
return $this->logs;
}

public function setLogs(Collection $logs): void
{
$this->logs = $logs;
}
}

Here is the Doctrine configuration. We will use an SQLite database for testing, and we will load the test Entity only during testing:

when@test:
doctrine:
dbal:
driver: pdo_sqlite
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
url: 'sqlite:///var/test.db'
orm:
mappings:
LogBundleTest:
is_bundle: false
dir: '%kernel.project_dir%/Bundles/LogBundle/tests/Util/Entity'
prefix: 'Bundles\LogBundle\tests\Util\Entity'
alias: LogBundle
resolve_target_entities:
Bundles\LogBundle\Entity\LogUserInterface: Bundles\LogBundle\tests\Util\Entity\User

Finally, here is a complete test for your bundle:

<?php

namespace Bundles\LogBundle\tests;

use Bundles\LogBundle\Entity\Log;
use Bundles\LogBundle\tests\Util\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class BundleTest extends KernelTestCase
{
public function testSomething(): void
{
self::bootKernel();

$container = static::getContainer();

/** @var EntityManagerInterface $entityManager */
$entityManager = $container->get(EntityManagerInterface::class);
$metaData = $entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($entityManager);
$schemaTool->updateSchema($metaData);

$user = new User();
$user->setName('test');
$entityManager->persist($user);

$user2 = new User();
$user2->setName('test2');
$entityManager->persist($user2);

$entityManager->flush();

$log = new Log();
$log->setText('I looked at the order #16');
$log->setUser($user);

$log2 = new Log();
$log2->setText('I looked at the order #17');
$log2->setUser($user);

$log3 = new Log();
$log3->setText('I looked at the order #18');
$log3->setUser($user2);


$entityManager->persist($log);
$entityManager->persist($log2);
$entityManager->persist($log3);
$entityManager->flush();

$this->assertEquals($user->getId(), $log->getUser()->getId());
$this->assertEquals($user->getId(), $log2->getUser()->getId());
$this->assertEquals($user2->getId(), $log3->getUser()->getId());
}
}

At the start of the test, we update/create the SQLite database. Then, we create two users with the User entity from the bundle and add logs for those users. Finally, we assert the user IDs on those logs.

Of course, this test should be refactored; creating an SQLite database should be done in your bootstrap code for tests, etc.

Conclusion

In the previous example, you saw how to make a bundle that uses an entity from the app scope independent and standalone. Additionally, you learned how to test such a bundle. This technique is not just a school example; it can be genuinely useful in your projects.

Pulling your code from the app context into bundles can make your project more modular. You saw an example of how to do that in one specific case.

That’s all, thanks for reading! I hope you enjoyed and found the story interesting.

--

--

Filip Horvat

Senior Software Engineer, Backend PHP Developer, Located at Croatia, Currently working at myzone.com