Using Laravel's pipeline to replace CMS content placeholders with values

Author: Ally
Published:

Summary:

Use the Laravel Pipeline component with many Pipes to replace arbitrary placeholders in DOM with computed values using a Model’s accessor

Table of Contents

  1. Why Pipelines
  2. Pipelines
  3. Template
  4. Pipeline Container
  5. Pipeline
  6. The Pipe interface
  7. My First Pipe
  8. Accessor

We have a CMS which will generate content based on a couple of attributes (region, date). The template (containing placeholders) for the system to calculate, is determined by one of these attributes.

Each of these templates consist of a few sections.

The raw template will be stored as part of the model (say content), and we can’t replace this content with the placeholders replaced with their semantic values at creation, as this would mean editing the post will take the values at the time, when the values could change later.

You could:

Why Pipelines

Simply put, you could get away with just using something like this for a small example:

public function getContentAttribute($content)
{
    $foo = 'bar';
    $bar = 'baz';
    
    return Str::of($content)
        ->replace('[foo]', $foo)
        ->replace('[bar]', $bar);
}

But when there are multiple sections (with multiple placeholders) making some potentially non-trivial queries/transformations, this gets out of hand very quickly.

The templates I am working with have 120+ placeholders.

Pipelines

A lesser documented component of the framework is Pipelines.

These are ideal for our use case because as mentioned above, the templates contain a few sections, so making a pipeline step for each section is much more manageable.

Template

Prior to creating the Article, I use the region and date from the request to determine which template is to be used.

The template is then set to $article->content.

Pipeline Container

I make a DTO to pass into the pipeline, since it can bundle a few different objects together, e.g.:

This container will have some getters/setters/helpers available to it too, there’s nothing really worth noting, however.

Pipeline

I construct the above DTO and pass this into the pipeline.

use Illuminate\Pipeline\Pipeline;
use App\Article\ArticlePipelineContainer;

// implementation detail for this is irrelevant
$container = ArticlePipelineContainer::get();

return app(Pipeline::class)
    ->send($container)
    ->through([
        // TODO: pipes to transform $container->output
    ])
    ->then(function (ArticlePipelineContainer $container): string {
        return (string) $container->getOutput();
    })

The Pipe interface

Each Pipe in the Pipeline will need to follow the following interface (obviously this can be changed):

<?php

namespace App\Article\Pipe;

use Closure;
use App\Article\ArticlePipelineContainer;

interface ArticlePipe
{
    public function handle(ArticlePipelineContainer $container, Closure $next);
}

My First Pipe

The transformation pipes will look something like this:

<?php

namespace App\Article\Pipe;

use Closure;
use App\Article\Pipe\ArticlePipe;
use App\Article\ArticlePipelineContainer;

class ReplacePlaceholdersForSummary implements ArticlePipe
{
    public function handle(ArticlePipelineContainer $container, Closure $next)
    {
        $container->setOutput(
            $container->getOutput()
                ->replace(
                    '[REGION_MONTH_MAX_CHANGE]',
                    $container->getStats()->getRegionMonthMaxChange()
                )
                ->replace(
                    '[REGION_MONTH_MIN_CHANGE]',
                    $container->getStats()->getRegionMonthMinChange()
                )
        );
        
        // a few more transformations...
        
        // remember to always call the next pipe
        return $next($container);
    }
}

Add the pipe to your pipeline’s through, it could end up looking like this:

use Illuminate\Pipeline\Pipeline;
use App\Article\ArticlePipelineContainer;
use App\Article\Pipe\ReplacePlaceholdersForSummary;

// implementation detail for this is irrelevant
$container = ArticlePipelineContainer::get();

return app(Pipeline::class)
    ->send($container)
    ->through([
        ReplacePlaceholdersForSummary::class,
        ReplacePlaceholdersForAbsoluteChangeMonthAscending::class,
        ReplacePlaceholdersForAbsoluteChangeMonthDescending::class,
        ReplacePlaceholdersForRelativeChangeMonthAscending::class,
        ReplacePlaceholdersForRelativeChangeMonthDescending::class,
        ReplacePlaceholdersForAbsoluteChangeYearAscending::class,
        ReplacePlaceholdersForAbsoluteChangeYearDescending::class,
        ReplacePlaceholdersForRelativeChangeYearAscending::class,
        ReplacePlaceholdersForRelativeChangeYearDescending::class,
    ])
    ->then(function (ArticlePipelineContainer $container): string {
        return (string) $container->getOutput();
    })

Instead of an absolute monstrosity. Thank you, pipeline!

Accessor

For the public facing side, it’s possibly a good idea to add an accessor on the model.

public function getContentAttribute($content): string
{
    if (filled($this->markup)) {
        return $this->markup;
    }

    return $content;
}

This means the original template ($article->content) remains editable with placeholders untouched, and you can listen for the updated event to run some service to update the $article->markup to be the output of the pipeline.

Note when updating the content (i.e. setting $article->output by running it through the pipeline) in an event as outlined above, remember to use $article->getRawOriginal('content'), otherwise you will either get into an infinite loop, or the template with the values already replaced (i.e. value from the accessor) going in to the pipeline.

Having it work this way means there is no cross-repository code to replace the placeholders values, e.g. admin repo (pipeline only) and public repo (accessor only).

Headless screenshot of a chart in Laravel with Browsershot and S3 upload
A Laravel middleware to optimise images with imgproxy on arbitrary markup
To bottom
To top