Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

A Global "Export" Action

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

There are actually three different types of actions in EasyAdmin. The first consists of the normal actions, like Add, Edit, Delete, and Detail. These operate on a single entity. The second type is batch actions, which operate on a selection of entities. For example, we can click two of these check boxes and use the Delete button up here. That is the batch Delete, and it's the only built-in batch action.

Side note: to make sure approved questions aren't deleted - which is work we just finished, you should also remove the batch Delete action for the Question crud. Otherwise, people might try to batch Delete questions. That won't work... thanks to some code we wrote, but they'll get a very-unfriendly 500 error.

Anyways, the third type of action is called a "global action", which operates on all the entities in a section. There are no built-in global actions, but we're going to add one: a button to "export" the entire questions list to a CSV file.

Creating the Global Action

For the most part, creating a global action... isn't much different than creating a normal custom action. It starts the same. Over in the actions config, create a new $exportAction = Action::new() and call it export. Below, we'll ->linkToCrudAction() and also call it export. Then, add some CSS classes... and an icon. Cool. We're ready to add this to the index page: ->add(Crud::PAGE_INDEX, $exportAction) to get that button on the main list page.

... lines 1 - 23
class QuestionCrudController extends AbstractCrudController
... lines 26 - 39
public function configureActions(Actions $actions): Actions
... lines 42 - 59
$exportAction = Action::new('export')
->addCssClass('btn btn-success')
->setIcon('fa fa-download');
... line 64
return parent::configureActions($actions)
... lines 66 - 81
->add(Crud::PAGE_INDEX, $exportAction);
... lines 84 - 187

If we stopped now, this would be a normal action. When we refresh... yup! It shows up next to each item in the list. Not what we wanted. To make it a global action, back in the action config, call ->createAsGlobalAction(). You can also see how you would create a batch action.

... lines 1 - 39
public function configureActions(Actions $actions): Actions
... lines 42 - 59
$exportAction = Action::new('export')
... lines 61 - 63
... lines 65 - 83
... lines 85 - 190

Now refresh and... awesome!

Coding up the Custom Action

If we click the new button, we get a familiar error... because we haven't created that action yet. To help build the CSV file, we're going to install a third party library. At your terminal, say:

composer require handcraftedinthealps/goodby-csv

How's that for a great name? The "goodby-csv" library is a well-known CSV package... but it hasn't been updated for a while. So "handcraftedinthealps" forked it and made it work with modern versions of PHP. Super helpful!

If you downloaded the course code, you should have a tutorial/ directory with a CsvExporter.php file inside. Copy that... and then, in your src/Service/ directory, paste. This will handle the heavy lifting of creating the CSV.

... lines 1 - 2
namespace App\Service;
... lines 4 - 12
class CsvExporter
public function createResponseFromQueryBuilder(QueryBuilder $queryBuilder, FieldCollection $fields, string $filename): Response
$result = $queryBuilder->getQuery()->getArrayResult();
// Convert DateTime objects into strings
$data = [];
foreach ($result as $index => $row) {
foreach ($row as $columnKey => $columnValue) {
$data[$index][$columnKey] = $columnValue instanceof \DateTimeInterface
? $columnValue->format('Y-m-d H:i:s')
: $columnValue;
... lines 28 - 62

At the bottom, this returns a StreamedResponse (that's a Symfony response)... that contains the file download with the CSV data inside. I won't go into the specifics of how this works... it's all related to the package we installed.

To call this method, we need to pass it three things: the QueryBuilder that should be used to query for the results, the FieldCollection (this comes from EasyAdmin and holds the fields to include), and also the filename that we want to use for the download. In QuestionCrudController, create that export() action: public function export().

... lines 1 - 23
class QuestionCrudController extends AbstractCrudController
... lines 26 - 189
public function export()

Reusing the List Query Builder

Ok, step 1 is to create a QueryBuilder. We could simply inject the QuestionRepository, make a QueryBuilder... and pass that to CsvExporter. But we're going to do something a bit more interesting... and powerful.

When we click the "Export" button, I want to export exactly what we see in this list, including the current order of the items and any search parameter we've used to filter the results. To do that, we need to steal some code from our parent class. Scroll up to the top of the controller... and then hold "cmd" or "ctrl" to open AbstractCrudController. Inside, search for "index". There it is.

So index() is the action that renders the list page. And we can see some logic about how it makes its query. We want to replicate that. Specifically, we need these three variables: this is where it figures out which fields to show, which filters are being applied, and ultimately, where it creates the QueryBuilder. Copy those... go back to our export() action, and paste. I'll say "Okay" to add a few use statements.

To get this to work, we need a $context. That's the AdminContext which, as you probably remember, is something we can autowire as a service into our methods. Say AdminContext... but this time, call it $context. Awesome!

... lines 1 - 9
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
... lines 11 - 14
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
... lines 16 - 25
class QuestionCrudController extends AbstractCrudController
... lines 28 - 193
public function export(AdminContext $context)
$fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
$filters = $this->container->get(FilterFactory::class)->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity());
$queryBuilder = $this->createIndexQueryBuilder($context->getSearch(), $context->getEntity(), $fields, $filters);
... lines 200 - 201

At this point, we have both the QueryBuilder and the FieldCollection that we need to call CsvExporter. So... let's do it! Autowire CsvExporter $csvExporter... then, at the bottom, it's as simple as return $csvExporter->createResponseFromQueryBuilder() passing $queryBuilder, $fields, and then the filename. How about, questions.csv

... lines 1 - 7
use App\Service\CsvExporter;
... lines 9 - 26
class QuestionCrudController extends AbstractCrudController
... lines 29 - 192
public function export(AdminContext $context, CsvExporter $csvExporter)
... lines 195 - 198
return $csvExporter->createResponseFromQueryBuilder($queryBuilder, $fields, 'questions.csv');

Let's try it! Refresh... hit "Export" and... I think it worked! Let me open that up. Beautiful! We have a CSV of all of our data!

Forwarding Ordering & Filtering Query Params to the Action

But... there is one hidden problem. Notice the ordering of these items. In the CSV file... it seems like they're in a random order. But if we look at the list in the browser, these are ordered by ID! Try searching for something. Cool. 7 results. But if we export again... and open it... uh oh! We get the same long list of results! So the Search in the CSV export isn't working either!

The problem is this: the search term and any ordering that's currently applied is reflected in the URL via query parameters. But when we press the "Export" button, we only get the basic query parameters, like which CRUD controller or action is being called. We do not also get the filter, search, or order query parameters. So then, when we get the $queryBuilder and $filter, the parameters aren't there to tell EasyAdmin what filtering and ordering to do!

How can we fix this? By generating a smarter URL that does include those query parameters.

Up in configureActions(), instead of ->linkToCrudAction(), let's ->linkToUrl() and completely take control. Pass this a callback function. Inside, let's create the URL manually.

... lines 1 - 52
public function configureActions(Actions $actions): Actions
... lines 55 - 72
$exportAction = Action::new('export')
->linkToUrl(function () {
... lines 75 - 79
... lines 81 - 102
... lines 104 - 218

You might remember that, to generate URLs to EasyAdmin, we need the AdminUrlGenerator service. Unfortunately, configureActions() isn't a real action - it's just a random method in our controller - and so we can't autowire services into it. But no problem: let's autowire what we need into the constructor.

Add public function __construct()... and then autowire AdminUrlGenerator $adminUrlGenerator and also RequestStack $requestStack. We're going to need that in a minute to get the Request object. Hit "alt" + "enter" and go to "Initialize properties" to create both of those properties and set them.

... lines 1 - 22
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
use Symfony\Component\HttpFoundation\RequestStack;
... lines 25 - 27
class QuestionCrudController extends AbstractCrudController
private AdminUrlGenerator $adminUrlGenerator;
private RequestStack $requestStack;
public function __construct(AdminUrlGenerator $adminUrlGenerator, RequestStack $requestStack)
$this->adminUrlGenerator = $adminUrlGenerator;
$this->requestStack = $requestStack;
... lines 38 - 216

Back down in configureActions()... here we go... inside ->linkToUrl(), get the request: $request = $this->requestStack->getCurrentRequest(). Then, for the URL, create it from scratch: $this->adminUrlGenerator, then ->setAll($request->query->all(). This starts by generating a URL that has all of the same query parameters as the current request. Now, override the action - ->setAction('export') and then ->generateUrl().

... lines 1 - 52
public function configureActions(Actions $actions): Actions
... lines 55 - 72
$exportAction = Action::new('export')
->linkToUrl(function () {
$request = $this->requestStack->getCurrentRequest();
return $this->adminUrlGenerator->setAll($request->query->all())
... lines 81 - 102
... lines 104 - 218

Basically, this says:

Generate the same URL that I have now... but change the action to point to export.

Testing time! Refresh the page. We should have 7 results. Export, open that up and... yes! Got it! It shows the same results... and in the same order as what we saw on the screen!

Next, let's learn to re-order the actions themselves and generate a URL from our frontend show page so that we can have an "edit" button right here for admin users.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", //
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0