With the Symfony framework, most objects are supposed to live in the Service Container. This facilitates an “autowire” of all the class constructors, plus a great deal of magic from the debug tools.
Most objects are supposed to be “shared” as well. This is Symfony’s substitute for the singleton pattern. It helps prevent duplication of “services”. However, services don’t always fit this pattern.
One of the Symfony configuration tutorials divides object classes into only two types. In that limited example, objects would have to be “services that do work” or “models that hold data”. Again, services don’t always fit into this pattern.
Let’s look at a custom class that does both. We will name it MyDataEncap
and task it with encapsulating a Doctrine Entity, named MyData
, and offering getters and setters that convert data between the storage format and the display format. Each time a MyData
entity is added or retrieved, a new instance of MyDataEncap
is needed. This pattern is an example of a third type of object class, a “Non Shared Service”.
In the config/services.yaml
file, it is possible to designate a sub-directory where all non-shared services will live. This avoids manual configuration of each new service class.
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
App\Utility\:
resource: '../src/Utility/'
shared: false
In this example, I’ve used the default configuration where all services are autowired and saved in src/
except that all src/Utility/
classes are designated as non-shared. The custom class will be saved in src/Utility/MyDataEncap.php
and its full name will be App\Utility\MyDataEncap
.
The challenge is that Symfony doesn’t provide a simple method to autowire these non-shared services. If we attempt to use a MyDataEncap
directly from a controller constructor, we will get only one object. And because the controller is a shared service, there would only ever be that one controller with that one MyDataEncap
. Going back to plain PHP, the controller can assign a new MyDataEncap()
but this breaks the autowiring of the custom service when it needs to get other services.
What we need is a Service Closure. Think of it as an anonymous factory for the service.
Symfony offers a few ways to do this, but the MyDataEncap
example is among the most simple scenarios. We simply need a factory for this one service, and potentially for a small number of other non-shared utility services as the project grows. To accomplish that, let’s use the Autowire Service Closure Attribute pattern.
Here’s an example of it in use in a PHP 8 controller:
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class MyController extends AbstractController
{
public function __construct(
#[AutowireServiceClosure('App\Utility\MyDataEncap')]
private \Closure $myDataEncapCallable,
) {
// Property Promotion
}
#[Route('/', name: 'app_home')]
public function home( Request $request ): Response
{
$encap = ($this->myDataEncapCallable)();
return ....
}
}
These small changes to the controller are pretty much the only requirement.
I will briefly explain why this works. The new constructor attribute references the MyDataEncap
service, allowing proper construction of a closure, as well as dependency usage for debugging. The extra parentheses in the call to the closure are required. This can be abstracted with an extra controller method if desired.
To get beyond the most basic pattern of constructing and autowiring these custom objects, we need to consider initializing the service. I won’t get into much more detail in this post, but let’s look at the original task of encapsulating a MyData
entity. It will look much better if we don’t end up with separate calls to the factory and then to initialize the service every time an entity is retrieved from the database. However, I couldn’t find an autowire technique for this. Instead, I added an initializer method to the new service that will return its own object just like a constructor. Now I will simply add a chained call to the initializer with the MyData
parameter.
namespace App\Controller;
use App\Repository\MyDataRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class MyController extends AbstractController
{
public function __construct(
#[AutowireServiceClosure('App\Utility\MyDataEncap')]
private \Closure $myDataEncapCallable,
private MyDataRepository $repo,
) {
// Property Promotion
}
#[Route('/', name: 'app_home')]
public function home( Request $request ): Response
{
$myData = $this->repo->find(1);
$encap = ($this->myDataEncapCallable)()->myInitializer( $myData );
return ....
}
}
In that step, I added a reference to a Doctrine repository. This can be cleaned up even more by moving the closure from the controller to the repository. As long as it extends the ServiceEntityRepository
, all of the autowiring will still work inside of the repository, and then the controller can simply call on a custom repository method to retrieve encapsulated objects from the database.
Using this same pattern, adding other non-shared services can be as simple as copying the property promotion and changing a couple of names. There are more patterns available as the project becomes more complex, but I wanted to offer a basic tutorial to make this an easy place to get started.