It’s no secret that PyroCMS, being built on Laravel, is extremely extensible. Between custom addons and overrides, it’s possible to add and alter any functionality in Pyro. When thinking about how you want to extend a core addon, there’s two options:
- Extend and Override core addon classes in your addon
- Add listeners for fired events on core addon classes in your addon
In general, I’m not a fan of the first option, for a couple of reasons:
- It doesn’t support class transformation: if any of the core addon classes, for example, the
UserFormBuilder
, make use of class transformation for its properties (such asUserFormFields
), then you will need to manually recreate / extend each of these transformed classes. For example, YourCustomUserFormBuilder
would need aCustomUserFormFields
class which extendsUserFormFields
. This is a lot of additional boilerplate classes. - It doesn’t play well with other addons. If multiple addons are extending or overriding core classes, then you create race conditions where they cannot co-exist.
As such, it’s my opinion that adding listeners to core classes is much more efficient and extensible way of extending core modules.
But how do we use events to extend core addons?
Here’s a list of all the core Event Classes that are fired in Pyro as of 3.4:
Addons (Extensions, Modules, Field Types)
AddonWasRegistered ExtensionWasDisabled ExtensionWasEnabled ExtensionWasInstalled ExtensionWasMigrated ExtensionWasRegistered ExtensionWasUninstalled FieldTypeWasRegistered ModuleWasDisabled ModuleWasEnabled ModuleWasInstalled ModuleWasMigrated ModuleWasRegistered ModuleWasUninstalled
Fields (Fields, Assignments)
FieldWasCreated FieldWasDeleted FieldWasSaved FieldWasUpdated AssignmentWasCreated AssignmentWasDeleted AssignmentWasSaved AssignmentWasUpdated GridIsQuerying
Streams (Streams, Models, Entries)
StreamWasCreated StreamWasDeleted StreamWasSaved StreamWasUpdated ModelWasCreated ModelWasDeleted ModelWasRestored ModelWasSaved ModelWasUpdated EntryWasCreated EntryWasDeleted EntryWasRestored EntryWasSaved EntryWasUpdated
Forms
FormWasBuilt FormWasPosted FormWasSaved FormWasValidated
Tables
TableIsQuerying
Views
TemplateDataIsQuerying
Console
StreamsIsRefreshing
Tree
TreeIsQuerying
In this example, we’re going to extend the Posts Module to add a column and filter for Post Types, let’s assume we’re doing this inside our admin theme acme-admin_theme
.
Creating The Listener Class
We firstly need to create a listener in our addon:
<?php namespace Craigberry\AcmeAdminTheme\Listener; use Anomaly\PostsModule\Post\Table\PostTableBuilder; use Anomaly\PostsModule\Type\Contract\TypeRepositoryInterface; use Anomaly\PostsModule\Type\TypeModel; use Anomaly\Streams\Platform\Addon\FieldType\FieldTypeBuilder; use Anomaly\Streams\Platform\Support\Evaluator; use Anomaly\Streams\Platform\Support\Resolver; use Anomaly\Streams\Platform\Ui\Table\Component\Filter\Type\SelectFilter; use Anomaly\Streams\Platform\Ui\Table\Component\Header\Header; use Anomaly\Streams\Platform\Ui\Table\Event\TableIsQuerying; use Illuminate\Database\Eloquent\Builder; /** * Class AddTypeFilterAndColumn * @package Craigberry\AcmeAdminTheme\Listener */ class AddTypeFilterAndColumn { /** * @var TypeRepositoryInterface */ protected $types; /** * AddTypeFilterAndColumn constructor. * @param TypeRepositoryInterface $types */ public function __construct(TypeRepositoryInterface $types) { $this->types = $types; } /** * @param TableIsQuerying $event */ public function handle(TableIsQuerying $event) { $builder = $event->getBuilder(); $query = $event->getQuery(); if (get_class($builder) == PostTableBuilder::class) { $this->addTypeFilter($builder); $this->addTypeColumn($builder); $this->getFilteredQuery($query); } } /** * Add the post type column to the table. * * @param PostTableBuilder $builder */ protected function addTypeColumn(PostTableBuilder $builder) { $builder->addColumn([ 'value' => function ($entry) { return $entry->getTypeSlug(); } ]); $header = new Header(); $header->setSortColumn('type_id'); $header->setSortable(true); $header->setBuilder($builder); $header->setHeading('craigberry.theme.acme_admin::field.post_type.name'); $builder->getTable()->addHeader($header); } /** * Add a filter for post type. * * @param PostTableBuilder $builder */ protected function addTypeFilter(PostTableBuilder $builder) { $filter = new SelectFilter(app(FieldTypeBuilder::class), app(Resolver::class), app(Evaluator::class)); $filter->setPlaceholder('craigberry.theme.acme_admin::field.post_type.name'); $filter->setOptions($this->getOptions()); $filter->setSlug('type'); $builder->getTable()->addFilter($filter); } /** * Filter by post type if present in request. * * @param Builder $query * @return Builder */ protected function getFilteredQuery(Builder $query) { if ($filterType = request('filter_type')) { $query->where('type_id', '=', $filterType); } return $query; } /** * Get post type options for select. * * @return array */ private function getOptions() { $options = []; foreach ($this->types->all() as $type) { $options[$type->getId()] = $type->getName(); } return $options; } }
There’s a lot to digest here, so we’ll go through it method by method.
The Constructor
<?php /** * @var TypeRepositoryInterface */ protected $types; /** * AddTypeFilterAndColumn constructor. * @param TypeRepositoryInterface $types */ public function __construct(TypeRepositoryInterface $types) { $this->types = $types; }
This is pretty straightforward. We’re using dependency injection to store the TypeRepositoryInterface
instance in a protected attribute for us to use in other methods.
The Handler
This is where we call the individual methods.
/** * @param TableIsQuerying $event */ public function handle(TableIsQuerying $event) { $builder = $event->getBuilder(); $query = $event->getQuery(); if (get_class($builder) == PostTableBuilder::class) { $this->addTypeFilter($builder); $this->addTypeColumn($builder); $this->getFilteredQuery($query); } }
If you check the source code of the TableIsQuerying
class, you’ll see it provides two methods for getting the table builder and the query. We’re going to use this in order to alter those queries where necessary.
We do a simple get_class()
check on the $builder to ensure that we’re only firing these events on the PostTableBuilder
. This is important as hooking listeners to events will fire on all builders etc. We want to make sure we only use it on the PostTableBuilder
. We then fire off our methods to add the Filter, the Column and handle the filtered query.
addTypeColumn
/** * Add the post type column to the table. * * @param PostTableBuilder $builder */ protected function addTypeColumn(PostTableBuilder $builder) { $builder->addColumn([ 'value' => function ($entry) { return $entry->getTypeSlug(); } ]); $header = new Header(); $header->setSortColumn('type_id'); $header->setSortable(true); $header->setBuilder($builder); $header->setHeading('craigberry.theme.acme_admin::field.post_type.name'); $builder->getTable()->addHeader($header); }
In this method, we’re adding a generic column to the builder that has Type slug as it’s value. Then we’re creating a new Header
and injecting it into the headers for the table.
addTypeFilter
/** * Add a filter for post type. * * @param PostTableBuilder $builder */ protected function addTypeFilter(PostTableBuilder $builder) { $filter = new SelectFilter(app(FieldTypeBuilder::class), app(Resolver::class), app(Evaluator::class)); $filter->setPlaceholder('craigberry.theme.acme_admin::field.post_type.name'); $filter->setOptions($this->getOptions()); $filter->setSlug('type'); $builder->getTable()->addFilter($filter); }
In this method, we’re creating a new SelectFilter
and setting it’s options to be all post types in our private method getOptions()
. We then inject it into the filters for the table.
/** * Get post type options for select. * * @return array */ private function getOptions() { $options = []; foreach ($this-types->all() as $type) { $options[$type->getId()] = $type->getName(); } return $options; }
Pretty basic. Use the repository to fill an array. Could simplify/clean this up by using array helpers / collection mappers, but I’ve kept it basic for understanding.
getFilteredQuery
/** * Filter by post type if present in request. * * @param Builder $query * @return Builder */ protected function getFilteredQuery(Builder $query) { if ($filterType = request('filter_type')) { $query->where('type_id', '=', $filterType); } return $query; }
This method simply checks if there is a filter_type
set in the request and then alters the query builder to search on type_id.
Hooking it up to a Service Provider
All we need to do now is hook it up to the event in our AcmeAdminThemeServiceProvider
:
/** * The addon event listeners. * * @type array|null */ protected $listeners = [ TableIsQuerying::class => [ AddTypeFilterAndColumn::class ] ];
The Results:
And this is what it looks like in operation:
Conclusion
Pyro core is insanely extensible, and by using events, you can add any functionality you need without altering source code or creating race conditions in your addons. Over time more events will get added, and I’ll try to update this list as it happens.