Friday 7 April 2023

PHP / Symfony: working through "Symfony: The Fast Track", part 4: not really about Symfony, this one


Once again I'm gonna continue working through "Symfony: The Fast Track". This will be part four of this series, after the first three:

The page I'm starting on is Setting up an Admin Backend, which sounds very situation-specific to me (so: not very portable knowledge to acquire), but I guess I'll learn "Symfony's way of ~". And there might be some useful stuff in there. Let's see.

Setting up an Admin Backend

Installing more dependencies

First they describe some Symfony concepts: Symfony Components: low-level stuff like routing, HTTP etc; and Symfony Bundles: higher-level stuff like wrappers for third-party libs. Fair enough.

Hrm. More Symfony opinions now. There's a "feature" of Symfony "aliases". Their wording:

Aliases are shortcuts for popular Composer packages. Want an ORM for your application? Require orm. Want to develop an API? Require api. These aliases are automatically resolved to one or more regular Composer packages. They are opinionated choices made by the Symfony core team.
Symfony: The Fast Track › Setting up an Admin Backend › Installing more Dependencies

Righto then.

This is a bit pathetic:

Another neat feature is that you can always omit the symfony vendor. Require cache instead of symfony/cache.

Mate. Yer bragging about omitting seven keystrokes. Well: other than the fact I've got to type in symfony composer rather than just composer. How was this even a good use of anyone's dev time, let alone brag about it being not only a feature, but a "neat" one? Sigh.

They're getting me to do this: symfony composer req "admin:^4", which installs easycorp/easyadmin-bundle, and adds a reference to it in config/bundles.php,and adds config/packages/uid.yaml. The latter looks intriguing, I guess I'll find about about it later.

Configuring EasyAdmin

Next up I'm configuring this EasyAdmin thing:

root:/var/www# symfony console make:admin:dashboard

 Which class name do you prefer for your Dashboard controller? [DashboardController]:

 In which directory of your project do you want to generate "DashboardController"? [src/Controller/Admin/]:

[OK] Your dashboard class has been successfully generated.
Next steps: * Configure your Dashboard at "src/Controller/Admin/DashboardController.php" * Run "make:admin:crud" to generate CRUD controllers and link them from the Dashboard. root:/var/www#

This has added a controller with some boilerplate (I'll spare you, but I'll link through to it in source control once it's there). There's a slight bug in the generation, in that it has not picked up my app's PSR-4 namespace, so it's written out this:

namespace App\Controller\Admin;
Which I will change to be:
namespace adamcameron\symfonythefasttrack\Controller\Admin;

I kinda think this should have looked at the maker.yaml config file that Symfony itself suggested I create to set the root_namespace (see It looks like your app may be using a namespace other than "App" in the second article of this series). Ah well: never mind: it's an easy fix.

It's also put an annotation in to handle the routing which I am not gonna run with, I'll stick it in the routing config where it belongs.

And because I'm changing something here, I'm gonna make a test for it. I should really have done this before running the wizard I guess. I didn't think about it. I also didn't know what the wizard was gonna do at the time, however I could have read ahead. Anyways, this has configured a /admin/ end point, so I will make sure it returns a 200:

namespace adamcameron\symfonythefasttrack\tests\Acceptance\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

/** @testdox Tests the endpoints in the DashboardController */
class DashboardControllerTest extends WebTestCase
    /** @testdox The index endpoint returns a 200 response */
    public function testIndex()
        $client = static::createClient();
        $client->request('GET', '/admin/');

        $this->assertEquals(Response::HTTP_OK, $client->getResponse()->getStatusCode());

(This changes in the next step to be a 302, but I adjusted the test accordingly behind the scenes).

I've added a new test suite too, for acceptance tests (hitting the "front" of pages, and verifying they do whatever they are supposed to. For now, 200ing will be fine).

This test still passes after I reconfigure the routing. And I also get a page rendering in the browser:

Next I'm building a CRUD UI for the entities I've created. This is done via symfony console make:admin:crud.I'll spare you the detail unless there's something interesting.


Nope, nothing interesting except it asked me what namespace to use this time, so I was able to give it the correct one. It defaulted to Symfony's default App one though.

The process has created two skeleton controller classes. They don't have any routing in them, which is "interesting". The next step is wiring them into that initial dashbaord page I created, so let's see - just in the browser - what happens when I've done that.

public function configureMenuItems(): iterable
    yield MenuItem::linkToDashboard('Dashboard', 'fa fa-home');
    yield MenuItem::linktoRoute('Back to the website', 'fas fa-home', 'homepage');
    yield MenuItem::linkToCrud('Conferences', 'fas fa-map-marker-alt', Conference::class);
    yield MenuItem::linkToCrud('Comments', 'fas fa-comments', Comment::class);

A controller should only be busying itself with marrying-up which model should provide the data for the response, and which view to use to render the data. Here it is defining the data too. I mean this is a third-party package it's demonstrating here, so this approach is not strictly Symfony's doing; but Symfony is choosing to use this package, so I don't think it's great that official Symfony guidance should be encouraging poor practice like this.

It's also got me to reconfigure the index route handler method to redirect to the CRUD UI for conferences:

public function index(): Response
    $routeBuilder = $this->container->get(AdminUrlGenerator::class);
    $url = $routeBuilder->setController(ConferenceCrudController::class)->generateUrl();

    return $this->redirect($url);

When I reloaded the index page, I got a 500 error: the conference table was missing. Ah. I have rebuilt the containers since the last article, and I blew away the volume the DB data was in. I need to rerun the migration to get those tables back.

"Doctrine: know your limits!"(*)

Oh for goodness sake.

I just re-ran the migration to recreate the Conference and Comment tables, and I noticed... Symfony blew away the rest of the tables in the database (OK, granted, there was only one other table). WTH, Symfony? Then I looked at the migration file:

public function up(Schema $schema): void
    // this up() migration is auto-generated, please modify it to your needs
   // [snip for brevity]
    $this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
    $this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE [etc]');
    $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C604B8382 FOREIGN KEY [etc]');
    $this->addSql('DROP TABLE test');

You what, son?. Who the hell told you to do that? (/me hastily checks the make:migration process to confirm I didn't just go "yeah yeah yeah, delete the other tables. Cos like of course I want you to do that". No, I had not).

I googled about the place, and found this: Doctrine › Migrations › Generating Migrations › Ignoring Custom Tables:

If you have custom tables which are not managed by Doctrine you will need to tell Doctrine to ignore these tables. Otherwise, everytime you run the diff command, Doctrine will try to drop those tables. You can configure Doctrine with a schema filter.

What? Just: no. Doctrine, I have said "map these entities". Your job is to - let's see if you follow my thinking here - map these entities. Nothing else. Leave the rest of the frickin DB alone. It's not your business. Jesus.

At least they go on to say:

If you use the DoctrineBundle with Symfony you can set the schema_filter option in your configuration.

And over on the Symfony side of things: Symfony › Bundles › DoctrineMigrationsBundle › Manual Tables:

If you follow a specific scheme you can configure doctrine/dbal to ignore those tables. Let's say all custom tables will be prefixed by t_. In this case you just have have to add the following configuration option to your doctrine configuration:

        schema_filter: ~^(?!t_)~

Note that if you have multiple connections configured then the schema_filter configuration will need to be placed per-connection.

OK, two things.

  • What if you're not a lunatic from the 1990s and don't put hungarian notation on the beginning of things, so there isn't a prefix to match all the rest of the tables in your database?
  • Why the hell are you tightly coupling this to connection config? If I don't want Doctrine to delete my shit, then that's not gonna be - by default - connection specific. It's gonna be a blanket "don't delete my shit, you weirdo!?" across the board. I can see how maybe it could be overridden on a connection-by-connection basis (maybe?), but this should be a top-level Doctrine config thing (and "don't do it" being the default behaviour).

Ugh. But OK, I've added this to my connection:

        default_connection: default
                wrapper_class: Doctrine\DBAL\Connections\PrimaryReadReplicaConnection
                dbname: '%env(resolve:POSTGRES_PRIMARY_DB)%'
                host: '%env(resolve:POSTGRES_PRIMARY_HOST)%'
                port: '%env(resolve:POSTGRES_PRIMARY_PORT)%'
                user: '%env(resolve:POSTGRES_PRIMARY_USER)%'
                password: '%env(resolve:POSTGRES_PRIMARY_PASSWORD)%'
                driver: pdo_pgsql
                server_version: 15
                charset: utf8
                schema_filter: ~^(?!test)~
                        dbname: '%env(resolve:POSTGRES_REPLICA_DB)%'
                        host: '%env(resolve:POSTGRES_REPLICA_HOST)%'
                        port: '%env(resolve:POSTGRES_REPLICA_PORT)%'
                        user: '%env(resolve:POSTGRES_REPLICA_USER)%'
                        password: '%env(resolve:POSTGRES_REPLICA_PASSWORD)%'
                        charset: utf8

I guess I'm lucky I only have the one table to exclude, or that "pattern" could get quite weighty, quite quickly.

As an aside: I found out that when running symfony console make:migration, it'll try to run all the files in the migrations directory. As each has its own time-stamp-unique file name, I probably only want to keep the most recent one in source control? Or at least only one in that directory at any given time, anyhow. There might be a way of telling it to only run one migration. I should look into that.

Anyway: I have all my tables back in the DB now, so the page renders an empty Conference CRUD page:

And it all works fine once I allow the /admin/ route to process POST requests instead of just GETs (this is on me: I didn't realise I'd be needing to allow POSTs when I set up the routing).

I had less luck initially adding in comments, as the steps are slightly out of order in the book. I got onto this fix via googling "symfony the fast track comment UI broken", and landed on the GitHub issue to get it fixed: [Book] Step 9 Issue: Cannot create new Comment. So: in case you are following along here: after the step of testing/trying out the "Add conference" functionality, don't continue to add a comment; skip ahead and do the "Customizing EasyAdmin" bit first. It's just the next step.

This step adds code to configure how the form fields should work:

class CommentCrudController extends AbstractCrudController
    public static function getEntityFqcn(): string
        return Comment::class;

    public function configureCrud(Crud $crud): Crud
        return $crud
            ->setEntityLabelInSingular('Conference Comment')
            ->setEntityLabelInPlural('Conference Comments')
            ->setSearchFields(['author', 'text', 'email'])
            ->setDefaultSort(['createdAt' => 'DESC'])

    public function configureFilters(Filters $filters): Filters
            return $filters

    public function configureFields(string $pageName): iterable
        yield AssociationField::new('conference');
        yield TextField::new('author');
        yield EmailField::new('email');
        yield TextareaField::new('text')
        yield TextField::new('photoFilename')

        $createdAt = DateTimeField::new('createdAt')->setFormTypeOptions([
                'html5' => true,
                'years' => range(date('Y'), ((int)date('Y')) + 5),
                'widget' => 'single_text',
        if (Crud::PAGE_EDIT === $pageName) {
            yield $createdAt->setFormTypeOption('disabled', true);
        } else {
            yield $createdAt;

(Again: directly in the controller. Bleah).

That's it for that page. It didn't really have much to do with Symfony though, did it? It was all about this third-party admin app that I have no interest in whatsoever. This is no slight on EasyAdmin, it looks slick. But this is a Symfony book, not an EasyAdmin book.

I'm a bit annoyed at my experiences working through that page, so I'm leaving off for now.

There not much code that I would be willing to put my name next to in this effort, but I'll link to it anyhow: 1.8.

The next part is here: PHP / Symfony: working through "Symfony: The Fast Track", part 5: Twig stuff, and irritation.



(*) [cough]