Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Conditionally Disabling an 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

Okay, new goal. This page lists all of the questions on our site.. while the Pending Approval page lists only not approved questions. So ID 24 is not approved. We can see this same item on the main Questions page... and at the end of each row, there's a link to delete that question.

I want to change this so that only non-approved questions can be deleted. For instance, we should be able to delete question 24, but not question 13 because it's an approved question. How can we do that?

Since we're talking about questions, let's go to QuestionCrudController. The most obvious place is configureActions(). After all, this is where we configure which actions our CRUD has, which action links appear on which page, and what permissions each has. We can even call ->disable() and pass an action name to completely disable an action for this CRUD.

Actions and Action Objects

But, that's not what we want to do here. We don't want to disable the "delete" action everywhere, we just want to disable it for some of our questions. To figure out how to do that, we need to talk more about the Actions and Action classes.

The Actions class is basically a container that says which actions should be on which page. So it knows that on our index page, we want to have a "show" or "detail" action, "Edit" action, and "Delete" action.

This Actions object is actually created in DashboardController. It also has a configureActions() method. And if we jump into the parent method, yup! This is where it creates the Actions object and sets up all the default actions for each page. So PAGE_INDEX will have NEW, EDIT, and DELETE actions... and PAGE_DETAIL will have EDIT, INDEX, and DELETE. We also added the DETAIL action to PAGE_INDEX.

Notice that when we use the ->add() method - or when our parent controller uses it - we pass a string for the action name. Action::EDIT is a just constant that resolves to the string "edit".

But, behind the scenes, EasyAdmin creates an Action object to represent this. And that Action object knows everything about how that action should look, including its label, CSS classes, and other stuff. So really, this Actions object is a collection of the Action objects that should be displayed on each page.

And if you did find yourself with an Action object - I'll jump into that class - there would be all kinds of things that you could configure on it, like its label, icon, and more. It even has a method called displayIf() where we can dynamically control whether or not this action is displayed.

So... great! We could use that to conditionally hide or show the delete link! Yep! Except that... inside of configureActions(), to do that, we need a way to get the Action object for a specific action... like "give me the Action object for the "delete" action on the "index" page. Then we could call ->displayIf() on that.

But... this doesn't work. There's no way for us to access the Action object that represents the DELETE action on the PAGE_INDEX. So... does this mean that the built-in actions added by DashboardController can't be changed?

Thankfully, no! We can tweak these Action objects thanks to a nice function called ->update(). Say ->update(Crud::PAGE_INDEX, Action::DELETE), and then pass a callback that will receive an Action argument.

Using Actions::displayIf()

Perfect! This now means that, after the DELETE action object is created for PAGE_INDEX, it will be passed to us so we can make changes. For now, just dd($action).

... lines 1 - 21
class QuestionCrudController extends AbstractCrudController
{
... lines 24 - 37
public function configureActions(Actions $actions): Actions
{
return parent::configureActions($actions)
->update(Crud::PAGE_INDEX, Action::DELETE, static function(Action $action) {
dd($action);
})
... lines 44 - 49
}
... lines 51 - 123
}

If we refresh... yup! It dumped the Action object, as expected... which has an ActionDto object inside... where all the data is really held.

Back in the callback, add $action->displayIf() and pass this another callback: a static function() that will receive a Question $question argument. Now, each time the DELETE action is about to be displayed on the index page - like for the first, second then third question, etc - it will call our function and pass us that Question. Then, we can decide whether or not the delete action link should be shown. Let's show the delete link if !$question->getIsApproved().

... lines 1 - 37
public function configureActions(Actions $actions): Actions
{
return parent::configureActions($actions)
->update(Crud::PAGE_INDEX, Action::DELETE, static function(Action $action) {
$action->displayIf(static function (Question $question) {
return !$question->getIsApproved();
});
})
... lines 46 - 51
}
... lines 53 - 127

Sweet! Let's see what happens. Refresh and... error!

Call to a member function getAsDto() on null

Boo Ryan. I always do that. Inside update(), you need to return the action. There we go, much better!

And now... if we check the menu... look! The "Delete" action is gone! But if you go down to ID 24 - which is not approved - it's there! That's awesome!

Forbidding Deletes Dynamically

But, this isn't quite good enough. We're hiding the link on this one page only. And so, we should repeat this for the DELETE action on the detail page. And... you may need to disable the delete batch action entirely.

But even that wouldn't be enough... because if an admin somehow got the "Delete" URL for an approved question, the delete action would still work. The action itself isn't secure.

To give us that extra layer of security, right before an entity is deleted, let's check to see if it's approved. And if it is, we'll throw an exception.

To test this, temporarily comment-out this logic and return true... so that the delete link always shows. Back to the Questions page... got it!

... lines 1 - 37
public function configureActions(Actions $actions): Actions
{
return parent::configureActions($actions)
->update(Crud::PAGE_INDEX, Action::DELETE, static function(Action $action) {
$action->displayIf(static function (Question $question) {
// always display, so we can try via the subscriber instead
return true;
//return !$question->getIsApproved();
});
})
... lines 48 - 53
}
... lines 55 - 129

Now go to the bottom of QuestionCrudController. Earlier we overrode updateEntity(). This time we're going to override deleteEntity()... which will allow us to call code right before an entity is deleted. To help my editor, I'll document that the entity is going to be an instance of Question.

... lines 1 - 128
/**
* @param Question $entityInstance
*/
public function deleteEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
... lines 134 - 138
}
... lines 140 - 141

Now, if ($entityInstance->getIsApproved()), throw a new \Exception('Deleting approved questions is forbidden'). This is going to look like a 500 Error to the user... so we could also throw an "access denied exception". Either way, this isn't a situation that anyone should have... unless we have a bug in our code or a user is trying to do something they shouldn't. Bad admin user!

... lines 1 - 131
public function deleteEntity(EntityManagerInterface $entityManager, $entityInstance): void
{
if ($entityInstance->getIsApproved()) {
throw new \Exception('Deleting approved questions is forbidden!');
}
parent::deleteEntity($entityManager, $entityInstance);
}
... lines 140 - 141

I won't try this, but I'm pretty sure it would work. However, this is all a bit tricky! You need to secure the actual action... and also make sure that you remember to hide all the links to this action with the correct logic.

Life would be a lot easier if we could, instead, truly disable the DELETE action conditionally, on an entity-by-entity basis. If we could do that, EasyAdmin would hide or show the "Delete" links automatically... and even handle securing the action if someone guessed the URL.

Is that possible? Yes! We're going to need an event listener and some EasyAdmin internals. That's next.

Leave a comment!

2
Login or Register to join the conversation

Hi, Is there any difference between putting static function and just put function in Closure?

Reply

Hey Sergio,

Sure, there is a difference! But the difference is subtle :) Basically, if you need to use "$this" inside the closure to access the context of the current class - you can't declare that function as "static", as you may already know from static vs non-static methods :) And that's mostly it. Well, technically, for performance reasons, it's better to make your closures as static if you can... but if you have to use "$this" inside - nevermind about that static :)

Cheers!

1 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
    }
}