Improving cohesion in Symfony - storing Twig templates with the code
Whilst I’m a big fan of Symfony, I do find for larger apps the default directory structure leaves a lot to be desired.
The default structure is all about organising code by technical concern, so there are directories for Entities
,
Controllers
, Forms
etc.
Improving the default layout with feature directories
I much prefer to organise code by business concern, which keeps all code related to a specific feature or sub-feature together in close quarters.
This means you end up with a feature directory containing a mixture of form types, form data classes (if you use DTO’s for forms, which I also recommend), controllers, maybe a couple of UI related services, or a Twig extension. You get the picture.
This leads to a typical directory structure as follows:
├── src
│ └── UI
│ └── Feature
│ └── SendRegistrationEmail
│ └── ...
└── templates
└── registration-email
└── ...
A step further - moving templates into the feature directory
Recently, I have taken this a step further and moved the templates themselves into the feature directory, so that the templates that are directly concerned with providing the feature are right alongside the PHP code that uses them.
This has the advantage that you don’t have to wade through your entire /templates
directory to find the file you need,
and it’s immediately clear which templates relate to which feature.
├── src
│ └── UI
│ └── Feature
│ └── SendRegistrationEmail
│ └── tpl
└── templates
We keep the original /templates
directory to hold global stuff like page layout templates, commonly used widgets etc.
However, anything specific to the feature moves into a tpl
sub-directory of the feature directory, thus explicitly
showing which templates are used by that feature.
In order to get Twig to find these template files in their new location, the immediate solution is to add the following to the Twig configuration:
# config/packages/twig.yaml
twig:
# ...
paths:
'src/UI/Feature': 'Feature'
Then in your controllers you can refer to these templates using the namespace syntax:
@Feature/SendRegistrationEmail/tpl/hello.html.twig
Any Disadvantages?
Yes, there are two main disadvantages:
1) Specifying a directory that holds PHP code, such as src/UI/Feature
means that all code in that directory is now available to Twig, which is potentially a security issue and cause other problems.
2) When rebuilding the Symfony cache, the TemplateCacheWarmer
has to trawl through the entire src/UI/Feature
directory looking for template files, which adds an overhead and slows down the cache rebuild.
Both of these problems could be fixed by specifying multiple namespaces in the Twig configuration, but who wants to do that? It would be a maintenance nightmare and you’d end up with potentially hundreds of lines of configuration code for the multitude of features in your app.
Compiler Passes To The Rescue!
We can create a Symfony compiler pass that will automatically find and register any tpl
directories inside our src/UI/Feature
directory, and assign them to their own namespaces.
<?php
declare(strict_types=1);
namespace AppCommonInfrastructureTwig;
use SymfonyComponentDependencyInjectionCompilerCompilerPassInterface;
use SymfonyComponentDependencyInjectionContainerBuilder;
use SymfonyComponentFinderFinder;
/**
* Locate all "tpl" directories inside the UI/Feature directory, and
* add them to the Twig configuration as namespaced paths.
*
* A template such as "src/UI/Feature/Foo/Bar/tpl/test.twig.html" will
* be added with namespace "Foo->Bar", and therefore can be referenced
* using "@Foo->Bar/test.twig.html".
*/
final class TemplateDirectoryCompilerPass implements CompilerPassInterface
{
private const BASE_DIRECTORY = 'src/UI/Feature';
private const NAMESPACE_SEPARATOR = '->';
private const TEMPLATE_DIRECTORY_NAME = 'tpl';
public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('twig.loader.native_filesystem'))
{
$twigFilesystemLoaderDefinition = $container->getDefinition('twig.loader.native_filesystem');
$projectDir = $container->getParameter('kernel.project_dir');
$featureDir = sprintf('%s/%s', $projectDir, self::BASE_DIRECTORY);
foreach ($this->tplDirPaths($featureDir) as $file)
{
$tplDirToAdd = str_replace($projectDir . '/', '', $file->getPathname());
$namespaceToAdd = str_replace(
['src/UI/Feature/', '/tpl', '/'],
['', '', self::NAMESPACE_SEPARATOR],
$tplDirToAdd
);
$twigFilesystemLoaderDefinition->addMethodCall('addPath', [$tplDirToAdd, $namespaceToAdd]);
}
}
}
private function tplDirPaths(string $featureDir): Finder
{
$finder = new Finder();
$finder->directories()
->in($featureDir)
->name(self::TEMPLATE_DIRECTORY_NAME)
;
return $finder;
}
}
And then register the compiler pass with the Symfony kernel.
// App/Kernel.php
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(
new TemplateDirectoryCompilerPass()
);
}
Remove the “Feature” namespace from the Twig configuration:
# config/packages/twig.yaml
twig:
# ...
paths:
'src/UI/Feature': 'Feature' # <---- remove this line
Finally, update your Twig template paths, which can easily be done with a simple Regex search and replace:
For a single level deep:
Search @Feature/([^/]+)/tpl
and replace with @$1
.
For two levels deep etc:
Search @Feature/([^/]+)/([^/]+)/tpl
and replace with @$1->$2
.
This then gives us the wonderfully expressive template paths such as:
@SendRegistrationEmail/hello.html.twig
and for sub-features we would have something like:
@Billing->Invoicing->Create/invoice.html.twig
Your view on my choice of the ->
may vary, but personally I like it, and it is not possible to use /
so an
alternative must be chosen. I think this provides good readability.
Summary And Benefits
This approach makes multiple improvements to an app and is very quick and easy to implement:
- improved cohesion of closely related files, both PHP and Twig
- fewer lines of YAML (or PHP!) configuration code to manage
- more succinct template paths, providing better readability and less noise
It may not be necessary for smaller applications, but I’m finding it extremely beneficial in some of the larger apps that I manage.
Going Further - Moving Unit Tests Too
This is not considered a standard approach to locating template files, certainly within the Symfony community. However, for larger apps I do think it provides tangible benefits.
An additional and similar step I’ve also made, again not something widely done in the Symfony community as far as I am
aware, is to locate unit tests alongside the production code (I prefer inside a Tests
subdirectory).
Again this locates closely related code together in the same place, and it’s very easy to see where you are missing a
unit test for a class. There’s also no need to keep the directory structure of the /tests
root in sync with the /src
root directory.
Currently there is poor support in PHPStorm for locating tests alongside the production code, which some may find
annoying. Also, you need to make changes to your deployment or packaging process to remove the test code, but I find
this is easy enough if you have tests in a Tests
subdirectory as any directory with this name can easily be filtered
out when deploying. End to end and integration tests would stay in their traditional location inside the root /tests
directory.