If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeTo understand more about sub requests, let's create one by hand! Because, it's not super obvious what these two Twig functions are really doing behind-the-scenes.
Insides our homepage controller, let's execute a sub request right here. How? It's simpler than you might think. Step 1: create a new request object: $request = new Request()
. This is a totally empty Request
object: it basically has nothing in it.
... lines 1 - 15 | |
class ArticleController extends AbstractController | |
{ | |
... lines 18 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 39 | |
// manual sub-request example | |
$request = new Request(); | |
... lines 42 - 54 | |
} | |
... lines 56 - 84 | |
} |
It's not like the Request::createFromGlobals()
method that we saw earlier. That method pre-populates the object with all the current request information. This does not do that. To render the partial controller, set the request attribute: $request->attributes->set('_controller')
and set that to the same string we have inside our Twig template. I'll copy that... and paste it here: 'App\\Controller\\PartialController::trendingQuotes'
.
... lines 1 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 41 | |
$request->attributes->set('_controller', 'App\\Controller\\PartialController::trendingQuotes'); | |
... lines 43 - 54 | |
} | |
... lines 56 - 86 |
We now have a Request
object with nothing in it except for an _controller
attribute. And... that's all we need! Well, to work around some internal validation that checks for a valid IP address, we also need to say $request->server->set('REMOTE_ADDR', '127.0.0.1')
.
... lines 1 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 42 | |
$request->server->set('REMOTE_ADDR', '127.0.0.1'); | |
... lines 44 - 54 | |
} | |
... lines 56 - 86 |
To send this into HttpKernel
, we can fetch that service. Yes, even the mighty HttpKernel
is a service in the container. Add another argument: HttpKernelInterface $httpKernel
. Then, down here, we can say $response = $httpKernel->handle()
. We're going to pass this two arguments. We already know from index.php
that the first argument is the Request
. So, pass $request
. But there is also an optional second argument: the request "type". This allows you to pass a flag that indicates if this is a "master" request - that's the default - or if this is a sub-request - some request that is happening inside the main one. That's our situation, so pass: HttpKernelInterface::SUB_REQUEST
.
... lines 1 - 10 | |
use Symfony\Component\HttpKernel\HttpKernelInterface; | |
... lines 12 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 44 | |
$response = $httpKernel->handle( | |
$request, | |
HttpKernelInterface::SUB_REQUEST | |
); | |
... lines 49 - 54 | |
} | |
... lines 56 - 86 |
What difference will that make? Not much. But listeners to almost every event that we've seen are passed this flag on the event object and can behave differently based on whether or not a master or sub request is being handled. We'll see that in a few minutes.
To check if this works, dump($response)
.
... lines 1 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 48 | |
dump($response); | |
... lines 50 - 54 | |
} | |
... lines 56 - 86 |
Um... ok! Let's try this! We added this to the homepage... so refresh. Everything looks normal on this main request. Now hover over the target icon on the web debug toolbar. There it is! A dumped Response
with the trending quotes content inside.
And, yes, if we click the time icon on the web debug toolbar to get to the Performance section of the profiler, we can see our sub request! Heck, now we have two sub requests: our "manual" sub-request and then the one from the template.
Set the threshold back down to 0 milliseconds. Way down on the main profiler, the sub-request shows up as this strange __section__.child
thing.
Go back to the homepage controller and comment out the sub request logic.
... lines 1 - 34 | |
public function homepage(ArticleRepository $repository, LoggerInterface $logger, $isMac, HttpKernelInterface $httpKernel) | |
{ | |
... lines 37 - 39 | |
/* | |
// manual sub-request example | |
$request = new Request(); | |
$request->attributes->set('_controller', 'App\\Controller\\PartialController::trendingQuotes'); | |
$request->server->set('REMOTE_ADDR', '127.0.0.1'); | |
$response = $httpKernel->handle( | |
$request, | |
HttpKernelInterface::SUB_REQUEST | |
); | |
dump($response); | |
*/ | |
... lines 52 - 56 | |
} | |
... lines 58 - 88 |
I wanted you to see that this is all that really happens to trigger a sub request.
As we talked about, many listeners will use this SUB_REQUEST
flag to change their behavior. Because sometimes, it only makes sense for a listener to do its work on the main, master request. For example - if you wrote a custom listener that checked the URL and denied access based on some custom logic, that listener only needs to do that check on the main request. It either denies access or allows access initially, and then the rest of the page should render normally.
Our UserAgentSubscriber
is a perfect example of this. It makes no sense to read the User-Agent
off of a sub request. It might work - because, in reality, sub-requests copy some of the data from the main request, but trying to read real information off of the request in a sub-request is asking for trouble. I really want you to think of the master and sub requests as totally independent objects.
So, what can we do? At the very top of our listener, if not $event->isMasterRequest()
, simply return.
... lines 1 - 10 | |
class UserAgentSubscriber implements EventSubscriberInterface | |
{ | |
... lines 13 - 19 | |
public function onKernelRequest(RequestEvent $event) | |
{ | |
if (!$event->isMasterRequest()) { | |
return; | |
} | |
... lines 25 - 37 | |
} | |
... lines 39 - 45 | |
} |
The isMasterRequest()
method is a shortcut to check the flag that was originally passed to HttpKernel::handle()
. Our listener will still be called on a sub-request, but now it will do nothing. And that makes sense: this class is doing nothing more than logging the User-Agent
. We didn't realize it before, but thanks to our sub-request, each page refresh was logging the User-Agent
twice: one for the main request and once for the sub-request.
Ok, but! We still haven't fixed our original problem: when we add ?mac=false
to the URL, this is correctly read on the master request but incorrectly on the sub request. That's because we're trying to read that query parameter from inside the sub request... which doesn't work.
How can we fix that? The answer leverages an old friend of ours and will also touch on the proper way to pass data from the main request to the sub-request if you want to use HTTP caching with edge side includes. That's next.
Hey Richard H.!
That's a really interesting question! It's not something I had ever thought of! I have 2 possible answers:
1) In the next version of Symfony 5.3), you will be able to generate a URL to a fragment - https://github.com/symfony/symfony/pull/40575 . That doesn't quite help you (as it adds a Twig function), but once you're on Symfony 5.3, you could use this FragmentHelper service directly - https://github.com/symfony/symfony/pull/40575/files#diff-e3ffd3a96358e306fbd17808a66899ade7231ad81279972c1cc9a12e34c84d45 - which will be autowireable via FragmentUriGeneratorInterface
.
2) As we've learned in this tutorial, Symfony reads the controller via an _controller request attribute. So, in a functional test, you could, in theory, set this directly on the request. From doing some digging, I don't see any super easy, clean way of doing this... which sort of make sense, the request attributes are not normally modifiable from the "outside"... so it's not easy to do this from your test. The easiest way would probably be to add a "back door" in your test environment only if you go with this - e.g. you set some special header on your request (e.g. FRAGMENT_CONTROLLER), then in your test environment only, register a subscriber that reads this and sets the request attributes.
Anyways, these are two... both imperfect solutions. I guess a 3rd solution would be to "make a route to your sub-request controller"... which is a bit more work, but it would certainly make your life easier. Heck, you could even - in config/routes/test/anyfile.yaml - register these routes only in the test environment.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-iconv": "*",
"antishov/doctrine-extensions-bundle": "^1.4", // v1.4.3
"aws/aws-sdk-php": "^3.87", // 3.133.20
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.12.1
"doctrine/doctrine-bundle": "^2.0", // 2.2.3
"doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // 2.2.2
"doctrine/orm": "^2.5.11", // 2.8.2
"easycorp/easy-log-handler": "^1.0", // v1.0.9
"http-interop/http-factory-guzzle": "^1.0", // 1.0.0
"knplabs/knp-markdown-bundle": "^1.7", // 1.9.0
"knplabs/knp-paginator-bundle": "^5.0", // v5.4.2
"knplabs/knp-snappy-bundle": "^1.6", // v1.7.1
"knplabs/knp-time-bundle": "^1.8", // v1.16.0
"league/flysystem-aws-s3-v3": "^1.0", // 1.0.24
"league/flysystem-cached-adapter": "^1.0", // 1.0.9
"league/html-to-markdown": "^4.8", // 4.9.1
"liip/imagine-bundle": "^2.1", // 2.5.0
"oneup/flysystem-bundle": "^3.0", // 3.7.0
"php-http/guzzle6-adapter": "^2.0", // v2.0.2
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"sensio/framework-extra-bundle": "^5.1", // v5.6.1
"symfony/asset": "5.0.*", // v5.0.11
"symfony/console": "5.0.*", // v5.0.11
"symfony/dotenv": "5.0.*", // v5.0.11
"symfony/flex": "^1.9", // v1.17.5
"symfony/form": "5.0.*", // v5.0.11
"symfony/framework-bundle": "5.0.*", // v5.0.11
"symfony/mailer": "5.0.*", // v5.0.11
"symfony/messenger": "5.0.*", // v5.0.11
"symfony/monolog-bundle": "^3.5", // v3.6.0
"symfony/property-access": "5.0.*|| 5.1.*", // v5.1.11
"symfony/property-info": "5.0.*|| 5.1.*", // v5.1.10
"symfony/routing": "5.1.*", // v5.1.11
"symfony/security-bundle": "5.0.*", // v5.0.11
"symfony/sendgrid-mailer": "5.0.*", // v5.0.11
"symfony/serializer": "5.0.*|| 5.1.*", // v5.1.10
"symfony/twig-bundle": "5.0.*", // v5.0.11
"symfony/validator": "5.0.*", // v5.0.11
"symfony/webpack-encore-bundle": "^1.4", // v1.11.1
"symfony/yaml": "5.0.*", // v5.0.11
"twig/cssinliner-extra": "^2.12", // v2.14.3
"twig/extensions": "^1.5", // v1.5.4
"twig/extra-bundle": "^2.12|^3.0", // v3.3.0
"twig/inky-extra": "^2.12", // v2.14.3
"twig/twig": "^2.12|^3.0" // v2.14.4
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.4.0
"fakerphp/faker": "^1.13", // v1.13.0
"symfony/browser-kit": "5.0.*", // v5.0.11
"symfony/debug-bundle": "5.0.*", // v5.0.11
"symfony/maker-bundle": "^1.0", // v1.29.1
"symfony/phpunit-bridge": "5.0.*", // v5.0.11
"symfony/stopwatch": "^5.1", // v5.1.11
"symfony/var-dumper": "5.0.*", // v5.0.11
"symfony/web-profiler-bundle": "^5.0" // v5.0.11
}
}
Is this also the way you would call fragment controllers in functional tests? Because usually, in a WebTestCase, you send a GET request to a given URL (which is unknown for a fragment / sub-request).