Highly Opinionated Thoughts on Programming

by Elnur Abdurrakhimov


Symfony Without Bundles

May 21, 2014


One of the first things developers new to the Symfony framework learn about is bundles. Everything in Symfony is a bundle, the documentation says.

Based on that thought enthusiastic developers happily hop the bundle bandwagon and start creating a bunch of bundles in their apps:

The list goes on and on. Sure, you don’t usually meet all these bundles in a single project, but something similar is surely happening.

Note that whatever is being discussed in this post relates to application specific code that’s not reusable and will not be shared with other projects. Bundles are still very great for reusable code that needs to be shared and integrated with Symfony, and I use them a lot myself, although infrastructural ones only. But bundles just don’t make much sense for the application code that’s not reusable. In this particular case bundles add more problems than benefits. Remember, one size doesn’t fit all and there are no silver bullets.

Problems With Multiple Bundles

When I started with Symfony, I’ve used the same approach and run into several problems with it:

A Single Bundle for the Whole Application

Having run into those problems, my next evolutionary step was to give up the idea of having multiple bundles in an application and switch to a single AppBundle for the whole application. That solved all the problems listed above.

One of the reasons developers create multiple bundles is to partition things like controllers and views so that it’s easier to find stuff that’s related to the frontend or the backend. Turns out you don’t need bundles for that; PHP namespaces do the job and do it great.

For example, here’s how one could separate frontend from backend:

AppBundle
├── Controller
│   ├── Admin
│   │   └── HomeController.php
│   └── HomeController.php
└── Resources
    └── views
        ├── Admin
        │   └── Home
        │       └── index.html.twig
        └── Home
            └── index.html.twig

Then you could refer to controller actions the following way:

AppBundle:Home:index
AppBundle:Admin\Home:index

And to templates the following way:

AppBundle:Home:index.html.twig
AppBundle:Admin\Home:index.html.twig

As you can see, subnamespaces and subfolders combined solve the problem of partitioning an app with sections. No need to create a bunch of bundles for that.

No Bundles at All

When I got used to this one bundle approach, I had another insight: if I have everything in a single bundle, what’s the point of having the bundle at all? The bundle was adding an additional level of folder and namespace nesting for no particular benefit. So the next evolutionary step was to move everything out of the bundle and get rid of the bundle itself.

I did that step-by-step, first moving out services, repositories, models, controllers, and views, but eventually I’ve managed to move out everything.

I’ve been using this approach for more than two years now and it works great for me.

Now let’s get to specifics.

Services Out of Bundles

Moving services out of bundles is easy. Actually, you didn’t need bundles for services in the first place. But services out of bundles is a prerequisite for controllers discussed next.

There’s nothing special to services here. You can define them in config.yml or create separate file services.yml and import it from config.yml.

Let’s say you want to define a user manager service. Here’s how the class would look:

 1 namespace Example\Manager;
 2 
 3 use Example\Model\User;
 4 use Example\Repository\UserRepository;
 5 
 6 class UserManager
 7 {
 8     /**
 9      * @var UserRepository
10      */
11     private $userRepository;
12 
13     /**
14      * @param UserRepository $userRepository
15      */
16     public function __construct(UserRepository $userRepository)
17     {
18         $this->userRepository = $userRepository;
19     }
20 
21     /**
22      * @param int $id
23      * @return User
24      */
25     public function find($id)
26     {
27         return $this->userRepository->find($id);
28     }
29 }

And here’s how you would define the service:

1 services:
2     user_manager:
3         class: Example\Manager\UserManager
4         arguments:
5             - "@user_repository"

Since I prefer to use annotations, I use JMSDiExtraBundle for that. Here’s how my user manager would look like:

 1 namespace Example\Manager;
 2 
 3 use Example\Model\User;
 4 use Example\Repository\UserRepository;
 5 use JMS\DiExtraBundle\Annotation\InjectParams;
 6 use JMS\DiExtraBundle\Annotation\Service;
 7 
 8 /**
 9  * @Service("user_manager")
10  */
11 class UserManager
12 {
13     /**
14      * @var UserRepository
15      */
16     private $userRepository;
17 
18     /**
19      * @InjectParams
20      *
21      * @param UserRepository $userRepository
22      */
23     public function __construct(UserRepository $userRepository)
24     {
25         $this->userRepository = $userRepository;
26     }
27 
28     /**
29      * @param int $id
30      * @return User
31      */
32     public function find($id)
33     {
34         return $this->userRepository->find($id);
35     }
36 }

To make annotations work of out bundles, I have the following in my config.yml:

1 jms_di_extra:
2     locations:
3         directories: "%kernel.root_dir%/../src"

This tells JMSDiExtraBundle to look for services in the src/ folder and all its subfolders. By default, this bundle would look for services in bundles only.

Controllers Out of Bundles

Since you can define controllers as services, we reuse the same approach on services discussed in the previous section:

 1 namespace Example\Controller;
 2 
 3 use Example\Manager\UserManager;
 4 use Example\Model\User;
 5 use JMS\DiExtraBundle\Annotation\InjectParams;
 6 use JMS\DiExtraBundle\Annotation\Service;
 7 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 8 
 9 /**
10  * @Service("user_controller")
11  * @Route("/user", service="user_controller")
12  */
13 class UserController
14 {
15     /**
16      * @var UserManager
17      */
18     private $userManager;
19 
20     /**
21      * @InjectParams
22      *
23      * @param UserManager $userManager
24      */
25     public function __construct(UserManager $userManager)
26     {
27         $this->userManager = $userManager;
28     }
29 
30     /**
31      * @Route("/{id}")
32      *
33      * @param int $id
34      * @return User
35      */
36     public function viewAction($id)
37     {
38         return $this->userManager->find($id);
39     }
40 }

To make the routes work, you need to do two things. First, note the @Route annotation on the class: @Route("/user", service="user_controller"). The trick is that service property that refers to the service name defined in the annotation above: @Service("user_controller").

Second, you need to refer to this controller from routing.yml:

1 site:
2     resource: Example\Controller\UserController
3     type: annotation

Templates Out of Bundles

Since templates are not “real” code, instead of keeping them in the src/ folder, they go to app/Resources/views. That folder is already used by Symfony, but it’s mostly used for overriding templates of third-party bundles. You can still override templates of other bundles by placing them into app/Resources/views, but since we have no bundles for the application code, our templates go into this folder as well. And that feels much more logical than having templates in the src/ folder.

Let’s say you have a template for displaying a user’s profile. With this approach it would go to app/Resources/views/User/view.html.twig. Its logical name would be :User:view.html.twig so that’s what you would use when referring to this template from other templates: ``.

If you’re using the @Template annotation, it gets a bit more interesting. Since the template guesser that’s being used for @Template throws an exception if a controller is not in a bundle, I’ve created a bundle that fixes that. I’ve also opened a PR to fix that in SensioFrameworkExtraBundle itself, but since it haven’t been merged yet, you can use my bundle instead.

So, just install my bundle and you can use the @Template annotation as usual:

 1 namespace Example\Controller;
 2 
 3 use Example\Manager\UserManager;
 4 use Example\Model\User;
 5 use JMS\DiExtraBundle\Annotation\InjectParams;
 6 use JMS\DiExtraBundle\Annotation\Service;
 7 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
 8 
 9 /**
10  * @Service("user_controller")
11  * @Route("/user", service="user_controller")
12  */
13 class UserController
14 {
15     /**
16      * @var UserManager
17      */
18     private $userManager;
19 
20     /**
21      * @InjectParams
22      *
23      * @param UserManager $userManager
24      */
25     public function __construct(UserManager $userManager)
26     {
27         $this->userManager = $userManager;
28     }
29 
30     /**
31      * @Route("/{id}")
32      * @Template
33      *
34      * @param int $id
35      * @return User
36      */
37     public function viewAction($id)
38     {
39         return [
40             'user' => $this->userManager->find($id),
41         ];
42     }
43 }

The template guesser from my bundle will map viewAction to :User:view.html.twig that’s supposed to be in app/Resources/views/User/view.html.twig.

Models Out of Bundles

Models go to the src/Example/Model folder and the Example\Model namespace. There’s nothing special regarding defining the models themselves.

Note that I call them models and not entities. Entities is an ORM term and models are not necessarily being persisted by an ORM or an ORM alone. Since the same models can be persisted by different means, I call them models. They can still be used even if there is no persistence used at all. Remember not to tie your models to an ORM.

So, assuming you’re using the Doctrine ORM and annotations for mapping, here’s how the user model would look like:

 1 namespace Example\Model;
 2 
 3 use Doctrine\ORM\Mapping\Column;
 4 use Doctrine\ORM\Mapping\Entity;
 5 use Doctrine\ORM\Mapping\GeneratedValue;
 6 use Doctrine\ORM\Mapping\Id;
 7 
 8 /**
 9  * @Entity
10  */
11 class User
12 {
13     /**
14      * @Id
15      * @GeneratedValue
16      * @Column(type="bigint")
17      *
18      * @var int
19      */
20     private $id;
21 
22     /**
23      * @return int
24      */
25     public function getId()
26     {
27         return $this->id;
28     }
29 }

To tell the Doctrine ORM to map the models out of bundles you need one last thing. Here’s how you would configure the ORM in config.yml:

 1 doctrine:
 2     # ...
 3 
 4     orm:
 5         auto_generate_proxy_classes: "%kernel.debug%"
 6         mappings:
 7             model:
 8                 type: annotation
 9                 dir: "%kernel.root_dir%/../src/Example/Model"
10                 prefix: Example\Model
11                 alias: Model
12                 is_bundle: false

With this configuration you can still refer to models with the colon notation: Model:User.

Translation Files Out of Bundles

This one is simple. Just put translation files into the app/Resources/translations folder.

Conclusion

Bundles are great for reusable code that you share between several projects. But they don’t make much sense for the application specific code that probably won’t ever be shared with other projects.

Sure, if while developing your application you find code that you want to reuse in other projects, extract it to a library and/or bundle and share it. But don’t shove the application code itself into bundles.

Writing application code without bundles is an example of applying a framework to your application instead of bending the application to a currently trending framework — no matter how great it is this week.



© Elnur Abdurrakhimov