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')
->linkToCrudAction('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
->createAsGlobalAction();
... 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())
->setAction('export')
->generateUrl();
})
... 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!

28
Login or Register to join the conversation
Txurdi Avatar

Hello,

this code uses filters, seraches and orders, but how can we use filds configured in the INDEX.
In your example, you have ID, Name, Topic... fields, but in the CSV only entity fields apear.
We can manually use "$queryBuilder->select(" to peronalize it, but how can we use the $fields in columns?

Thank you!

Reply
Colin-P Avatar
Colin-P Avatar Colin-P | posted 15 days ago

Hello SymfonyCasts,

I have a question about choosing the best way to export CSV (and other file formats in Symfony).

This tutorial uses Goodby-csv library but Symfony has a Serializer component and a CsvEncoder class that can do similar jobs.

Why use Goodby-csv instead of either of these libraries that could convert entities into a Csv format?

Thank you.

Reply

Hey Colin,

That's a good question. You could create your own CSV exporter service by leveraging the Serializer component, but you would end up reinventing the wheel. The Goodby-csv library seems to be pretty good managing memory and has a few other convenient features. You can give it a check here https://github.com/handcraftedinthealps/goodby-csv

Cheers!

Reply
Sergey-P Avatar
Sergey-P Avatar Sergey-P | posted 21 days ago

Hello! Is it possible to create the batch action without confirmation?

Reply

Hi Sergey-P!

As far as I can tell, no. The attributes that trigger that modal are built into the batch system - https://github.com/EasyCorp/EasyAdminBundle/blob/c0bde19c9ec3ee97e379b1c9f7d6d99e253eb7cf/src/Factory/ActionFactory.php#L147-L156 and the actual code that submits the batch actions form is built into the "submit" button on that modal - https://github.com/EasyCorp/EasyAdminBundle/blob/c0bde19c9ec3ee97e379b1c9f7d6d99e253eb7cf/assets/js/app.js#L291-L318

So, pretty sure the confirmation is required.. unless you did some serious craziness with JavaScript where you try to "undo" the modal and reimplement the form submit manually.

Cheers!

1 Reply
Fabrice Avatar

Hello, it seems that there is a problem to export the entities which have a property of type array json.

I just tried with my User entity which has the "roles" property. This causes a fatal error (other people seem to have had the same problem).

Would there be something to do to deal with it?

EDIT : Ok, I've the fix. Just encode to JSON if it's an array :


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) {
if($columnValue instanceof \DateTimeInterface)
{
$columnValue = $columnValue->format('Y-m-d H:i:s');
}

if(is_array($columnValue))
{
$columnValue = json_encode($columnValue, JSON_THROW_ON_ERROR);
}

$data[$index][$columnKey] = $columnValue;
}
}

// Humanize headers based on column labels in EA
if (isset($data[0])) {
$headers = [];
$properties = array_keys($data[0]);
foreach ($properties as $property) {
$headers[$property] = ucfirst($property);
foreach ($fields as $field) {
// Override property name if a custom label was set
if ($property === $field->getProperty() && $field->getLabel()) {
$headers[$property] = $field->getLabel();

// And stop iterating
break;
}
}
}

// Add headers to the final data array
array_unshift($data, $headers);
}

$response = new StreamedResponse(function () use ($data) {
$config = new ExporterConfig();
$exporter = new Exporter($config);
$exporter->export('php://output', $data);
});
$dispositionHeader = $response->headers->makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$filename
);
$response->headers->set('Content-Disposition', $dispositionHeader);
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');

return $response;
}
Reply
Tien dat L. Avatar
Tien dat L. Avatar Tien dat L. | Fabrice | posted 5 months ago

thank <3

1 Reply

Thanks for posting that Tien dat L.! I'm not surprised that our custom logic was a bit limited: it worked for our use-case, but obviously wasn't tested for JSON fields!

Cheers!

1 Reply
Tien dat L. Avatar
Tien dat L. Avatar Tien dat L. | posted 5 months ago

Hi, and I got error in Source code Finish/QuestionCrudController with : Call to a member function getAsDto() on null

Sorry I'm new to Symfony ...

Reply
Tien dat L. Avatar

fixed in configureActions() in
update(Crud::PAGE_INDEX, ACTION::DELETE, static function (Action $action) {
$action->displayIF() {};

add return $action;
}

pls update in Source code Finish

Reply

Hey Tien dat L.!

Sorry about that! You are 100% correct! I do this correctly in the video - https://symfonycasts.com/sc... - but the code got out of sync! I'm fixing that right now. I appreciate you mentioning it!

Cheers!

Reply
Tien dat L. Avatar
Tien dat L. Avatar Tien dat L. | posted 5 months ago

Hi, with export() function but got error :

Die Website ist nicht erreichbar Die Webseite unter https://localhost/de/admin?crudAction=export&crudControllerFqcn=App%5CController%5CAdmin%5CUserCrudController&menuIndex=1&signature=-OO_KGwT7k6xB-A_nK1ch5IA65UGj0_cz70VL58vX84&submenuIndex=-1 ist eventuell vorübergehend nicht verfügbar oder wurde dauerhaft an eine neue Webadresse verschoben.

Do there with EA 4.0.10, how can i fix, here is my code

public function configureActions(Actions $actions): Actions
{
$exportAction = Action::new('export')
->linkToUrl(function () {
$request = $this->requestStack->getCurrentRequest();

return $this->adminUrlGenerator->setAll($request->query->all())
->setAction('export')
->generateUrl();
})
->addCssClass('btn btn-success')
->setIcon('fa fa-download')
->createAsGlobalAction();

return parent::configureActions($actions)
->add(Crud::PAGE_INDEX, $exportAction)
}

public function export(AdminContext $context, CsvExporter $csvExporter)
{
$fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
$context->getCrud()->setFieldAssets($this->getFieldAssets($fields));
$filters = $this->container->get(FilterFactory::class)->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity());
$queryBuilder = $this->createIndexQueryBuilder($context->getSearch(), $context->getEntity(), $fields, $filters);

return $csvExporter->createResponseFromQueryBuilder($queryBuilder, $fields, 'user.csv');
}

Reply
Fabrice Avatar

I had the same error for an entity and it was because it had a json array type property (User::roles).

I feel like the service can't handle this kind of property

Reply

I know you're already seen it Fabrice, but for others, @kiuega posted a nice solution for this problem! https://symfonycasts.com/sc...

Reply
Tien dat L. Avatar

Hi weaverryan thank, i have other question,
now dql like " SELECT entity FROM App\Entity\Question entity " how can i custom export fields like
" SELECT id, name, slug FROM App\Entity\Question entity "

Reply

Hey Tien dat L.!

It sounds like you want to export different fields that what is exporting currently, correct? Right now, the export exports *every* field on Question. That's what the "SELECT entity" means: it says "grab ALL the data from the Question" entity. Then, we loop over that in CsvExporter and export everything.

If you want to only export a sub-set of fields, you could modify the query builder to only select those:


$queryBuilder->select('entity.id, entity.name');

You could put that in your code right BEFORE you pass the $queryBuilder to CsvExporter. This will change the query to only return some fields... and then the exporter will only export those fields.

Cheers!

Reply
Tien dat L. Avatar
Tien dat L. Avatar Tien dat L. | weaverryan | posted 5 months ago | edited

weaverryan thanks

Reply
Christophe R. Avatar
Christophe R. Avatar Christophe R. | posted 5 months ago

Hello, with the export() function, there an issue with
$filters = $this->get(FilterFactory::class)
With :
Attempted to call an undefined method named "get" of class "App\Controller\AdminQuestionCrudController".

Do there is someting new with EA 4.1?
Here is my fix, If it can help:


public function export(AdminContext $context, CsvExporter $csvExporter, FilterFactory $filterFactory)
{
$fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
$filters = $filterFactory->create($context->getCrud()->getFiltersConfig(), $fields, $context->getEntity());
$queryBuilder = $this->createIndexQueryBuilder($context->getSearch(), $context->getEntity(), $fields, $filters);

return $csvExporter->createResponseFromQueryBuilder($queryBuilder, $fields, 'societies.csv');
}

Another tips with the CsvExporter, if you have ArrayCollection of value, you can easely change the begin of the "public function createResponseFromQueryBuilder" with something like :


// Convert DateTime objects or array into strings
$data = [];
foreach ($result as $index => $row) {
foreach ($row as $columnKey => $columnValue) {

if($columnValue instanceof \DateTimeInterface){
$val = $columnValue->format('Y-m-d H:i:s');
}else if(is_array($columnValue)){
$val = implode(',', $columnValue);
}else{
$val = $columnValue;
}
$data[$index][$columnKey] = $val;
}
}
Reply
Fabrice Avatar

Use $this->container->get(FilterFactory::class)

Reply

Good catch on that! I was blindly copying and pasting - I should have noticed that!

The full story: in Symfony 6, the $this->get() was removed. It's not normally something you should use at all anymore. But for (good) technical reasons, EasyAdmin still grabs things directly out of a container (technically a "service locator"). Anyways, $this->get() was a shortcut for $this->container->get(), which is why the fix shared by Fabrice works. On the latest EasyAdmin (both v3 and v4), the code has already been refactored to use $this->container->get(). We used *just* an old enough EasyAdmin in this tutorial to still have the old code :). Oh, and your solution of autowiring FilterFactory is also AWESOME - very clever solution!

Side note: this IS correct in the video (I was using a new enough version of EasyAdmin), but wrong in the code (which was using a *slightly* older version). I'm fixing that right now thanks to these comments!

And thanks for posting your improvement to CsvExporter!

Cheers!

Reply
Fabrice Avatar

Hello ! This is just an awesome feature ! It should be part of EasyAdmin official repository, why not submit a PR ?

Reply

Hey Kiuega,

Thank you for your feedback about this feature. IIRC the bundle has/had an opened issue about it, but it requires a complex implementation and may have some edge cases with the other existent features... so it was a low-priority feature because of this. Feel free to find a related issue about it to get more info, and if the issue is still releveant and wasn't closed for some reasons I'm not aware of - please, feel free to submit a PR! It might be so that maintainers just do not have enough time to work on this feature.

Cheers!

1 Reply

Salut! Hope everything is going ok!
I would be grateful if you could please provide me with the code of EXPORT function. it didn't work with me! I know the tutoril is not ready but i should finish my project tomorrow! Thanks again and again for your understanding.
All the best,

Reply

Hey Lubna

I see it's really necessary for you, I can't just share it here, but if you have access to downloaded code, you can look into the finish/ directory from course code archive

Cheers!

1 Reply

Thanks, I downloaded it!

Reply

Hi again! Sorry if I am bothering you. I would be thankful and grateful if you could please save my career by launching this tutorial. My manger asked me again to add this feature and it still does not work with me. :'( :'(

Reply

Hey Lubna

I'm afraid this video will be released within a few days. There isn't much I can do, but you can download the source code as Vladimir said, and since there is the script content released already, you can follow it along. I hope it helps

Cheers!

Reply

Thanks! no worries if I lost my current job I will apply for a new one at SensioLabs.

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.0.2",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "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
    }
}