Symfony2: Using Assetic for CSS and JavaScript Management
Edit: Please read the first the first comment from Kris Wallsmith, this explains that you do not need to write any PHP at all to make use of Assetic, it can be configured directly from Twig templates. My follow up post Symfony2: Using Assetic from Twig has more on this. Nevertheless the following should give you some idea of what is happening when you use it.
In this post I will look at Assetic, Symfony2's asset manager.
I am recreating the Lime Thinking website using Symfony2 as a learning exercise. As part of our in house LimePickle framework we manage CSS and JS files using PHP. Our current code allows us to make up the served files from several source files to reduce HTTP requests as well as applying transformation such as minimisation to them. We also modify the URLS they are served from to include version numbers so that the files can be set to be cached for a long time but with users still getting the latest versions.
I was keen that this functionality is not lost, if not improved upon, by using Symfony2, so seeing that it has a module for exactly this was reassuring. The documentation at this stage is still sparse, so I am worked from the documentation on the GitHub page. What follows is the stages I went through to arrive at a usable controller.
I wanted to get to a point where I can route from a file name to a config file which will specify the assets to return without needing to make any changes in PHP code, our preference at Lime Thinking would be to store this configuration as XML. I started by just hard coding in the files to serve with a mind to abstracting this to a config file once I progressed to a point where this would be sensible.
The start point is to use an AssetCollection and pass it an array of FileAsset object referencing the files we want to serve, along with an array of filters to apply, in this case a YUICompressor. We can then set the Response to be a dump of the AssetCollection, this will be a combination of the contents of the files which have had the YUI Compressor applied to them.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\Asset\AssetCollection;
use Assetic\Asset\FileAsset;
use Assetic\Filter\Yui\CssCompressorFilter;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$resource = new AssetCollection(
array(
new FileAsset('path/to/overall.css'),
new FileAsset('path/to/social.css'),
),
array(
new CssCompressorFilter('/path/to/yuicompressor.jar')
)
);
$resource->load();
$response = new Response;
$response->SetContent($resource->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
The next stage is to abstract from this direct creation and to use an AssetMenager and a FilterManager. In this simplistic example it does not gain us much but it does allow the assets and filters to be specified in one location and then references to them used so they only need to be changed in one place if they are used in multiple places. The manager also ensures that the same file is not included twice and the filters are only applied once.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\Asset\AssetCollection;
use Assetic\Asset\AssetReference;
use Assetic\Asset\FileAsset;
use Assetic\AssetManager;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$am = new AssetManager();
$am->set('overall', new FileAsset('/path/to/overall.css'));
$am->set('social', new FileAsset('/path/to/social.css'));
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$resource = new AssetCollection(
array(
new AssetReference($am, 'overall'),
new AssetReference($am, 'social'),
)
);
$resource->ensureFilter($fm->get('yui_css'));
$resource->load();
$response = new Response;
$response->SetContent($resource->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
After this we can progress to using an AssetFactory with our managers to create the asset. Again the advantage is not apparent in this simplistic example. However once we start to pass in the files and filters as variables rather than being hard coded it will reduce what we need to pass in to simple arrays.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\AssetManager;
use Assetic\Factory\AssetFactory;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$am = new AssetManager();
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager($am);
$factory->setFilterManager($fm);
$resource = $factory->createAsset(
array('overall.css', 'social.css'),
array('yui_css')
);
$response = new Response;
$response->SetContent($resource->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
We are now back to referencing our assets directly even if the AssetFactory has removed the repetition of the path. We can get these back though if we go back to setting them in the AssetManager and useing the @ syntax to reference them by name:
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\Asset\FileAsset;
use Assetic\AssetManager;
use Assetic\Factory\AssetFactory;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$am = new AssetManager();
$am->set('overall', new FileAsset('/path/to/overall.css'));
$am->set('social', new FileAsset('path/to/social.css'));
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager($am);
$factory->setFilterManager($fm);
$resource = $factory->createAsset(
array('@overall', '@social'),
array('yui_css')
);
$response = new Response;
$response->SetContent($resource->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
We can further abstract away from this by using the LazyAssetManager, this allows us to pass a formula which is just a nested array specifying the assets and references. Again in the examples the values are hard coded in but it should be easy to see how we could now just pass in this array to allow the controller to be used for serving multiple resources. This version does not use asset references but they can still be used as with the previous example.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\AssetManager;
use Assetic\Factory\AssetFactory;
use Assetic\Factory\LazyAssetManager;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager(new AssetManager());
$factory->setFilterManager($fm);
$formula = array(
array('overall.css', 'social.css'),
array('yui_css'),
);
$lazyAm = new LazyAssetManager($factory);
$lazyAm->setFormula('main', $formula);
$response = new Response;
$response->SetContent($lazyAm->get('main')->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
As the asset files are not changing constantly and the actual combining and in particular compression are expensive operations the results can easily be cached. The following shows the caching taking place. No checking to see if the file exists is needed if we write them to the location they are served from with the filename the browser is looking for since Symfony2 routing will only be hit when the file does not exist. This is only useful for production though as the files will need deleting before the asset will be recreated.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\AssetManager;
use Assetic\AssetWriter;
use Assetic\Factory\AssetFactory;
use Assetic\Factory\LazyAssetManager;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager(new AssetManager());
$factory->setFilterManager($fm);
$formula = array(
array('overall.css', 'social.css'),
array('yui_css'),
);
$lazyAm = new LazyAssetManager($factory);
$lazyAm->setFormula('main', $formula);
$writer = new AssetWriter('/path/to/web');
$writer->writeManagerAssets($lazyAm);
$response = new Response;
$response->SetContent($lazyAm->get('main')->dump());
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
This is where the built in AsseticController proves useful as it will deal with caching for us, it is a good compromise I think, as it caches the output but recreates it if the files change. This is excellent for development work where we want to keep up to date without clearing caches but without the slow load time of doing the compression for each loaded asset. For production you could still switch to using something like the previous example or better yet use call something like the above from Phing or Ant as part of the build process, so no one has to take the hit of first load.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\AssetManager;
use Assetic\Cache\FilesystemCache;
use Assetic\Factory\AssetFactory;
use Assetic\Factory\LazyAssetManager;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use Symfony\Bundle\AsseticBundle\Controller\AsseticController;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction()
{
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager(new AssetManager());
$factory->setFilterManager($fm);
$formula = array(
array('overall.css', 'prettyPhoto.css', 'social.css'),
array('yui_css')
);
$lazyAm = new LazyAssetManager($factory);
$lazyAm->setFormula('main', $formula);
$cache = new FilesystemCache('/path/to/cache');
$request = new Request();
$controller = new AsseticController($request, $lazyAm, $cache);
$response = $controller->render('main');
$response->headers->set('Content-Type', 'text/css');
return $response;
}
}
We still need to do much of the work of above as we need to pass the LazyAssetManager to the AsseticController but it does do the caching work for us.
At this stage I decided I was ready to move away from hard coding in the formula and retrieve it from elsewhere. As where this is received from does not need to be fixed I decided to encapsulate this is an object which returns the formula. This means that the way the formula is stored is not fixed in the controller. In my case I am storing the formula in XML files and the FormulaLoader object converts these to the array. I have also moved to specifying a Resources folder rather then the CSS folder in particular so I can start using this for JavaScript as well. This change also meant abstracting the choice of MIME type to set.
<?php
namespace LimeThinking\SpringBundle\Controller;
use Assetic\AssetManager;
use Assetic\Cache\FilesystemCache;
use Assetic\Factory\AssetFactory;
use Assetic\Factory\LazyAssetManager;
use Assetic\Filter\Yui\CssCompressorFilter;
use Assetic\FilterManager;
use LimeThinking\SpringBundle\Asset\Loader\XMLLoader;
use Symfony\Bundle\AsseticBundle\Controller\AsseticController;
use Symfony\Component\HttpFoundation\Response;
class AssetController
{
public function resourceAction($name, $format)
{
$yui = new CssCompressorFilter('/path/to/yuicompressor.jar');
$fm = new FilterManager();
$fm->set('yui_css', $yui);
$factory = new AssetFactory('/path/to/css');
$factory->setAssetManager(new AssetManager());
$factory->setFilterManager($fm);
$file = $name.$format;
$formulaLoader = new XMLLoader;
$formula = $formulaLoader->GetFormula($file);
$lazyAm = new LazyAssetManager($factory);
$lazyAm->setFormula($file, $formula['formula']);
$cache = new FilesystemCache('/path/to/cache');
$request = new Request();
$controller = new AsseticController($request, $lazyAm, $cache);
$response = $controller->render($file);
$response->headers->set('Content-Type', $formula['contentType']);
return $response;
}
}
You may have noticed that despite many of my previous posts being on Dependency Injection I have not used it at all here and just directly created any dependencies. In my next post I will go through how to use Dependency Injection to clean up this controller.
More information on Assetic including the various filters currently available, other caching methods and integration with Twig, which I have not covered at all, can be found at the documentation at GitHub. The one thing I did not find was a way to easily implement version numbers in the asset URLs, which can avoid the browser having to request the file at all with suitable cache settings, can anyone help with this?