Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Liquid Laravel Package

keepsuit/liquid

PHP implementation of the Liquid template engine with Shopify-compatible syntax, tags, and filters. Build safe, flexible, user-editable templates in Laravel or any PHP app with customizable tags and drops.

View on GitHub
Deep Wiki
Context7

Getting Started

keepsuit/liquid is a PHP port of Shopify's Liquid templating language. The mental model is simple: parse a template string once, then render it repeatedly with different data. This makes it a good fit for anything where the template is data — database-stored email bodies, notification templates, CMS snippets, user-configurable report layouts — rather than for static view files you control at deploy time.

Install:

composer require keepsuit/liquid

Smallest useful example:

use Liquid\Template;

$template = new Template();
$template->parse('Hello, {{ name }}! You have {{ count }} item{% if count != 1 %}s{% endif %}.');
$template->parse(...); // parse once

echo $template->render(['name' => 'Ada', 'count' => 3]);
// Hello, Ada! You have 3 items.

Where to look first

  1. README.md — the supported tag/filter list. Do this before promising a feature to a stakeholder; Liquid has dialects, and this port does not implement every Shopify tag.
  2. src/Liquid/Template.php — the public surface you will actually use: parse, parseFile, render, setFileSystem, setCache, registerFilter, registerTag, setEscapeByDefault, setStrictVariables.
  3. src/Liquid/Context.php — how variables are resolved (dot access, brackets, arrays vs. objects). Half of all "why is this blank?" bugs are here.
  4. src/Liquid/Tag/ and src/Liquid/Filter/ — read one existing tag and one existing filter before writing your own; they are the canonical templates for your extensions.

First use case to build

Store a Liquid source string in a templates table, render it against an array of data, and return the string:

$source = $emailTemplate->body;           // from DB, editable in admin
$template = new Template();
$template->parse($source);

return $template->render([
    'user'  => ['name' => $order->user->name],
    'order' => ['total' => $order->formatted_total, 'items' => $items],
]);

That single flow — untrusted-ish template, trusted data, string out — is 80% of real usage. Everything below is making that flow safe, fast, and debuggable.

Implementation Patterns

Centralize a configured Template in a factory

Never scatter new Template() through the codebase. Template configuration (file system, cache, escaping, strict mode, registered filters and tags) should be applied in exactly one place, so dev and prod behave identically apart from strictness.

final class LiquidFactory
{
    public function __construct(private array $config) {}

    public function make(): Template
    {
        $template = new Template();

        $template->setFileSystem(
            new \Liquid\FileSystem\Local($this->config['views_path'])
        );

        $template->setCache(
            new \Liquid\Cache\Local($this->config['cache_path'])
        );

        $template->setEscapeByDefault($this->config['escape_by_default']);
        $template->setStrictVariables($this->config['strict_variables']);

        foreach ($this->config['filters'] as $filter) {
            $template->registerFilter(new $filter());
        }

        foreach ($this->config['tags'] as $name => $class) {
            $template->registerTag($name, $class);
        }

        return $template;
    }
}

Bind this as a singleton-ish service in Laravel and resolve it per render. The factory is cheap; the parsed template is what you want to reuse.

Cache the parse, not just the render

Parsing Liquid into a token/parse tree is the expensive part. setCache() gives you a filesystem-backed parse cache keyed by template source. For DB-stored templates, the cleanest pattern is to key your own cache on updated_at:

$key = "liquid:email:{$emailTemplate->id}:{$emailTemplate->updated_at->timestamp}";

$rendered = Cache::remember($key, now()->addHours(6), function () use ($emailTemplate, $data) {
    $template = app(LiquidFactory::class)->make();
    $template->parse($emailTemplate->body);
    return $template->render($data);
});

Invalidate on save, not on a timer. If you cache the rendered output, remember the data is part of the key — usually you want to cache only the parsed template and render per recipient.

Wrap models in Drops instead of exposing arrays of attributes

Liquid cannot call arbitrary PHP methods on objects, but it can read public properties and Drop methods. Wrapping Eloquent models in a Drop gives you an explicit allowlist — the right default when templates are authored by admins or customers.

final class UserDrop extends \Liquid\AbstractDrop
{
    public function __construct(private User $user) {}

    public function getName(): string  { return $this->user->name; }
    public function getEmail(): string { return $this->user->email; }
    // __toString() required by AbstractDrop for direct interpolation
}

$template->render(['user' => new UserDrop($order->user)]);
// {{ user.name }} works; {{ user.password }} renders blank

This is also your security boundary. Do not pass raw models and hope Liquid only reads the fields you intended.

Custom filters: keep them pure and argument-order-stable

Filters are the lowest-friction extension point and cover most formatting needs.

final class MoneyFilter extends \Liquid\AbstractFilter
{
    public function getName(): string { return 'money'; }

    public function execute($value, string $currency = 'USD'): string
    {
        return number_format((float) $value, 2) . ' ' . $currency;
    }
}

// {{ order.total | money: "EUR" }}

Rules that keep filters maintainable:

  • One filter per class, getName() matching the template-facing name exactly.
  • Type-cast inputs defensively ((float), (string)) — users pipe unexpected types and null into filters constantly.
  • Never let a filter throw on bad input; return the input unchanged or an empty string. A formatter should not take down a page render.

If your installed version exposes a callable registration (registerFilter('money', fn ($v) => ...)), use it for one-off project filters and reserve classes for anything reused or unit-tested.

Custom tags for anything the language can't express

Tags are the right answer for {% csrf %}, {% asset 'app.css' %}, {% price_of product %}, or {% slot %}-style blocks. The important part is that they live in your app, not in templates — the template stays portable and the PHP stays testable.

final class CsrfTag extends \Liquid\AbstractTag
{
    protected $name = 'csrf';

    public function parse(array &$tokens) { /* consume tokens up to %} */ }

    public function render(\Liquid\Context $context): string
    {
        return csrf_field();
    }
}

$template->registerTag('csrf', CsrfTag::class);

Custom tags are also where Liquid stops being safe to hand to untrusted authors — a tag that echoes config values, queries the DB, or reads files is effectively a mini-API. Audit your tag registry before shipping a template editor to customers.

Validate templates at save time, not at render time

Wrap parse() in a try/catch and surface \Liquid\Exception\ParseException in the admin form. Catching a broken template when an admin clicks Save is dramatically cheaper than catching it in a queue worker at 2am.

try {
    (new Template())->parse($request->input('body'));
} catch (\Liquid\Exception\ParseException $e) {
    return back()->withErrors(['body' => 'Template syntax error: ' . $e->getMessage()]);
}

Pair this with a preview endpoint that renders against sample data in strict mode.

Split production and preview strictness

Run with setStrictVariables(true) in local/testing and false in production. Strict mode turns "silent empty string" into a loud exception — invaluable while authoring, hostile in production where a missing optional field shouldn't 500 a page.

$template->setStrictVariables(app()->environment(['local', 'testing']));
$template->setEscapeByDefault(app()->environment('production'));

Gotchas and Tips

Escaping is not automatic by default. Liquid's default is to render {{ user_bio }} raw. If the output goes into HTML (emails, CMS pages), that is a stored-XSS vector the moment a customer controls any data. Either enable setEscapeByDefault(true) or be disciplined about | escape on every interpolated field. Note that escape_by_default changes the meaning of existing templates — test a handful of real templates before flipping it in production.

Strict variables off means typos fail silently. {{ user.nmae }} renders as an empty string with no warning. This is the single most common support ticket with any Liquid implementation. Mitigations: strict mode in dev, a template lint in CI, and a documented list of available variables per template type (render it as a help panel next to the editor).

Unknown filters are a footgun of their own. Depending on configuration, an unregistered filter either throws or silently no-ops. If you expose a | money filter to template authors, keep it registered in every factory path — including queue workers, Artisan commands, and tests. Centralizing construction in one factory class is how you guarantee that.

Templates are stateful across render() calls. The parsed Template holds a Context, and constructs like {% increment %} and {% assign %} accumulate. Do not share a single parsed Template instance across concurrent requests under Octane/Swoole, and don't assume two render() calls on the same instance are independent. Parse-and-render within one request scope, or clone per render.

{% include %} inherits the parent context and assign inside an include leaks back out. That is standard Liquid behaviour and it surprises people coming from PHP include-with-extract(). Pass explicit parameters where possible and keep partials self-contained.

Path handling for includes is a security boundary. FileSystem\Local resolves paths relative to a configured root, but if any part of the include name is user-controlled, validate it against an allowlist. Do not let a template author write {% include '../../../.env' %} and discover whether the port sanitizes ...

Filter argument syntax is strict and unfamiliar. {{ x | replace: "a", "b" }} — commas separate arguments, colons separate the filter from its first argument, and strings containing commas must be quoted. There is no named-argument support. Document this next to your template editor; it is the syntax users get wrong most often.

nil, blank, and empty are distinct. {% if x %} is false for nil and false but true for 0 and "". Use x == blank when you mean "missing or empty string", and prefer default for fallbacks: {{ user.name | default: 'there' }}. default only fires on nil, false, and empty string — not on 0, which bites people formatting numbers.

Comparison coercion is Liquid's, not PHP's. "1" == 1 and "a" == 0 behave the way Liquid specifies, not the way you'd expect from == in PHP. When templates start doing arithmetic or comparisons on string-ish inputs, cast before rendering to plain PHP scalars.

Parse errors point at the template, not your code. ParseException messages include the offending fragment; surface the raw message in the admin UI and log the template ID alongside it. When debugging a render (not parse) failure, reduce to the smallest template that reproduces — most RenderExceptions come from a filter receiving a type it can't handle, and the stack trace points into the filter, not the template line.

Watch out for whitespace. {% if %} blocks leave newlines in the output. Use whitespace control ({%- ... -%}, {{- ... -}}) in HTML emails where stray blank lines break layout, and test rendered output as a string, not by eyeballing the editor.

Pin the version. At 21 stars and zero dependents this is a small, single-maintainer package — the API is stable in practice but the tag/filter base classes and registration signatures have shifted across major versions. Use ^1.0 with a committed composer.lock, and read the changelog before a minor bump that touches AbstractTag or AbstractFilter.

It's interpreted, not compiled. There is no compilation to PHP, so OpCache won't help you. The performance levers are: parse cache on disk, render-output cache where data permits, and keeping includes shallow. If a template renders per-recipient in a loop, parse once outside the loop and only call render() inside.

Testing. Assert on exact rendered strings with realistic data, including a fixture for the "missing optional variable" case and one for the "user-supplied HTML" case. Those two tests catch the majority of regressions in production template rendering.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core