Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Creating a Custom Field

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

On the Answers CRUD, we just created this nice custom "Votes" template, which we then use by calling ->setTemplatePath() on the votes field. But we also have a votes field over in the Questions section... which still renders the boring way. I want to use our new template in both places.

Technically, doing this is super easy! We could copy ->setTemplatePath(), go up, open QuestionCrudController, find the votes field... then paste to use that template path.

But instead, let's create a custom field.

We know that a field class - like TextareaField or AssociationField - defines how a field looks on the index and detail pages... as well as how it's rendered on a form. A custom field is a great way to encompass a bunch of custom field configuration in one place so you can reuse it. And creating a custom field is pretty easy.

Creating the Custom Field

Down in the src/EasyAdmin/ directory, create a new PHP class called, how about, VotesField.

The only rule for a field is that it needs to implement FieldInterface. This requires us to have two methods: new and getAsDto(). But what you'll typically do is use FieldTrait to make life easier.

... lines 1 - 4
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Field\FieldInterface;
... lines 6 - 7
class VotesField implements FieldInterface
{
use FieldTrait;
... lines 11 - 15
}

Click to open that. Ok, this FieldTrait helps manage the FieldDto object, with a bunch of useful methods like setLabel(), setValue() and setFormattedValue() that all fields share.

So now, if you go to Code Generate - or "cmd + N" on a Mac - the only thing we need to implement is new(). This is where we customize all the options for the field.

... lines 1 - 7
class VotesField implements FieldInterface
{
... lines 10 - 11
public static function new(string $propertyName, ?string $label = null)
{
// TODO: Implement new() method.
}
}

Our votes field is currently an IntegerField. Hold "cmd" or "ctrl" to open that and look at its new() method... because we want our method to look very much like this... with a few differences. So copy all of this, close, head to VotesField... and paste. Hit "Ok" to add that use statement on top. I'll also remove OPTION_NUMBER_FORMAT. We won't need that... and it relates to a field configurator that I'll show you in a minute.

... lines 1 - 6
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
... line 8
class VotesField implements FieldInterface
{
... lines 11 - 12
public static function new(string $propertyName, ?string $label = null)
{
return (new self())
->setProperty($propertyName)
->setLabel($label)
->setTemplateName('crud/field/integer')
->setFormType(IntegerType::class)
->addCssClass('field-integer')
->setDefaultColumns('col-md-4 col-xxl-3');
}
}

Ok, good start! You may have noticed that ->setDefaultColumns() is crossed out. That's because it's marked as "internal". That usually means it's a function that we shouldn't use directly. But in this case, the documentation says that it is ok to use from inside of a field class... which is where we are!

At this point, we can customize anything! Like ->addWebpackEncoreEntries() to add an extra Webpack Encore entry that will be included when this field is used. What we want to do, instead of calling ->setTemplateName() so that it uses the standard integer field template, is to say ->setTemplatePath() and pass the same thing we have in AnswerCrudController, which is admin/field/votes.html.twig.

As a reminder to myself, I'll add some comments about which part controls the index and detail pages... and which part controls the form.

... lines 1 - 12
public static function new(string $propertyName, ?string $label = null)
{
return (new self())
... lines 16 - 17
// this template is used in 'index' and 'detail' pages
->setTemplatePath('admin/field/votes.html.twig')
// this is used in 'edit' and 'new' pages to edit the field contents
// you can use your own form types too
->setFormType(IntegerType::class)
... lines 23 - 24
}
}

Using the Custom field

Ok, that's it! Let's go use this!

In AnswerCrudController, change this to VotesField... and we don't need ->setTemplatePath() anymore.

... lines 1 - 12
class AnswerCrudController extends AbstractCrudController
{
... lines 15 - 19
public function configureFields(string $pageName): iterable
{
... lines 22 - 28
yield VotesField::new('votes', 'Total Votes')
... lines 30 - 35
}
}

Then, in QuestionCrudController, do the same thing. Add VotesField and... done! If we wanted to, we could even put this ->setTextAlign('right') inside the custom field... or remove it.

... lines 1 - 4
use App\EasyAdmin\VotesField;
... lines 6 - 14
class QuestionCrudController extends AbstractCrudController
{
... lines 17 - 21
public function configureFields(string $pageName): iterable
{
... lines 24 - 35
yield VotesField::new('votes', 'Total Votes')
... lines 37 - 55
}
}

Testing time! Over in Questions, refresh and... got it! And on the Answers page... it looks great there too!

Watch out for Missing Configurators

But one tiny word of warning. Now that we've changed from IntegerField to VotesField, if there's a field configurator for the IntegerField, it will no longer be used.

And... there is such a configurator. Back down in vendor/easycorp/easyadmin-bundle/src/Field/Configurator, you'll find IntegerConfigurator. This operates only when the field you're using is an IntegerField. And so, this configurator was being used until a second ago... but not anymore.

If you look inside, it does some work with a custom number format, which allows you to control the format that the number is printed. We don't really need this, but don't forget about the "field configurator" system... and how a custom field won't be processed in the same way.

Next, let's learn how to configure a bit more of the CRUD itself, like how a CRUD section is sorted by default, pagination settings, and more.

Leave a comment!

5
Login or Register to join the conversation
Szabolcs Avatar
Szabolcs Avatar Szabolcs | posted 2 days ago

Hello! I just wondering, why the Fields (IntegerField, TextField, etc.) are all marked as final classes? Let's say I want to have an own IntegerField which I use everywhere, with inheritance I just could extend the IntegerField, override the static "new"-method, but the Configurator-system would still work in the background. But why this is not allowed :-D? Thanks!

Reply

Hey Szabolcs,

This is a good question. Unfortunately, I’m not a maintainer of this bundle and you probably better to ask it in the repository of this bundle, or ask a question there if you want the exact reason behind of it.

Though I think I can give you hints why it might be done this way :) the real problem is in maintenance I think. Making some classes final unload the maintainer, ie the maintainer can easily change the code inside those clases without worrying about BC brakes. In turn, it allows easier to make new minor releases instead of making major ones that would contain BC brakes. Moreover, another important thing is that fields are simple, you can easily write a new standalone field, and if you want - just copy/paste the existent code from the core classes. That’s in my opinion and I hope it helps, but if you really curious - you can ask maintainers I think :)

Cheers!

Reply
Szabolcs Avatar

ok, thank you very much for the answer!

Reply

You're welcome! :)

1 Reply
Szabolcs Avatar

By the way, of course I can write a custom factory-class, which can create my IntegerField and the Configurator-system would work, I am just curious about the idea, to mark all Field-classes as final classes.

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