Extending Core Addons Via Events

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:

  1. 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 as UserFormFields), then you will need to manually recreate / extend each of these transformed classes. For example, Your CustomUserFormBuilder would need a CustomUserFormFields class which extends UserFormFields. This is a lot of additional boilerplate classes.
  2. 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.

Craig Berry Written by: