Search by

sugarcommerce / templates

arraypress

A small Blade-like template engine for plain PHP: auto-escaping by default, layouts and sections, includes, and compiled output cached to disk. Zero dependencies.

Package info

github.com/SugarCommerce/templates

Homepage

Issues

pkg:composer/sugarcommerce/templates

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

1.1.0 2026-10-22 09:56 UTC

This package is not auto-updated.

Last update: 2026-10-22 15:30:29 UTC


README

A small Blade-like template engine for plain PHP.

composer require sugarcommerce/templates

Zero dependencies. A template compiles to a PHP file, which is then required — there is no runtime interpreter, so what actually executes is the same PHP you would have written by hand.

Why

In a plain PHP template every single output has to remember its escaping call:

<td><?= htmlspecialchars( $order['email'], ENT_QUOTES, 'UTF-8' ) ?></td>

Miss one and you have cross-site scripting. Across two thousand lines of markup that is not a question of discipline, it is a question of when.

Here the default is safe and opting out is visible:

<td>{{ $order['email'] }}</td>     {{-- escaped --}}
<td>{!! $badge !!}</td>            {{-- raw, and you can see that it is --}}

Use

use SugarCommerce\Templates\Engine;

$engine = new Engine(
    root: __DIR__ . '/templates',
    cache: __DIR__ . '/var/cache/templates',
);

echo $engine->render( 'admin.orders', array( 'page' => $page ) );

admin.orders and admin/orders name the same file. Compiled output is cached and recompiled when the source changes; pass always: true in development, where a template's @includes can change without its own mtime moving.

Syntax

{{ $value }}              escaped output
{!! $value !!}            raw output
@json( $value )           JSON, safe inside <script> and in a quoted attribute
{{-- a note --}}          removed entirely, unlike an HTML comment
@@if                      a literal @if

@if ( $a )  @elseif ( $b )  @else  @endif
@unless ( $a )  @endunless
@isset ( $a )  @endisset
@empty ( $rows )  @endempty

@foreach ( $rows as $row )  @endforeach
@forelse ( $rows as $row )  @empty  @endforelse
@for ( $i = 0; $i < 3; $i++ )  @endfor
@while ( $more )  @endwhile
@continue        @continue ( $row['hidden'] )
@break           @break ( $i > 10 )

@extends( 'layouts.admin' )
@section( 'body' )  @endsection
@section( 'title', 'Orders' )
@yield( 'body' )   @yield( 'title', 'Untitled' )

@include( 'partials.row' )
@include( 'partials.row', array( 'label' => 'Total' ) )

@php  $total = array_sum( $amounts );  @endphp
@verbatim  {{ leftAlone }}  @endverbatim

An @ that is not one of these is left exactly as written, so hi@example.com and @media (min-width: 40rem) pass through untouched — including their spacing.

Layouts

{{-- templates/layouts/admin.blade.php --}}
<html>
  <title>@yield( 'title', 'Admin' )</title>
  <body>@yield( 'body' )</body>
</html>
{{-- templates/admin/orders.blade.php --}}
@extends( 'layouts.admin' )

@section( 'title', 'Orders' )

@section( 'body' )
  <table>
    @forelse ( $page->rows as $order )
      <tr><td>{{ $order['email'] }}</td></tr>
    @empty
      <tr><td>No orders yet.</td></tr>
    @endforelse
  </table>
@endsection

Output outside a section is discarded when a template extends something — what it contributes is its sections, and the layout decides where those land. Layouts nest: a layout may extend another.

Includes

A partial sees the including template's variables, which is most of what makes one worth extracting — it does not have to be handed every value it already had. Extra variables win over the enclosing scope:

@foreach ( $rows as $row )
  @include( 'partials.row' )                             {{-- sees $row --}}
  @include( 'partials.row', array( 'row' => $other ) )   {{-- overridden --}}
@endforeach

Variables whose names begin with __ are reserved by the engine and never travel into a partial.

What this is not

A template compiles to PHP and is then executed. A template is code. Never compile one somebody else wrote, and never let an operator edit one through a form — that is a remote-code-execution hole with extra steps. Content that needs editing at runtime belongs in a string with placeholders; see email-templates, which reads its bodies rather than including them, for that shape.

There is no $loop variable, no components, no view composers and no service container. The whole engine is three classes.

Escaping is for HTML

{{ }} makes a value safe as text between tags and as an attribute value in quotes. It does nothing useful anywhere else, and the engine cannot tell where it is printing:

  • In <script>, use @json( $value ). A backslash in '{{ $value }}' ends the string early; @json cannot be broken out of, and a </script> inside the value cannot close the block.
  • In href or src, check the scheme before the value gets here. javascript:alert(1) has nothing in it to escape.
  • In style, validate — a colour is #rrggbb or it is not used. red;background:url(…) survives every escaper and is still CSS.
  • Always quote attributes. Unquoted, a space starts a new one.

@section( 'title', $value ) and @yield( 'title', $default ) escape their value the way {{ }} does, like Blade's: an inline section and a default are text. A block section is captured output and is printed as it was captured, so markup you built on purpose goes in the block form:

@section( 'title', $product['name'] )                        {{-- escaped --}}
@section( 'footer' ){!! $links_html !!}@endsection          {{-- raw, visibly --}}

Before 1.1.0 the inline value and the default were printed raw.

The cache is code

Compiled templates are PHP that the next render requires. Anybody who can write to the cache directory can run anything as your application: keep it outside the document root and writable by the application alone.

Printing the wrong thing

{{ $rows }} where $rows is an array throws, naming the type. Printing the word Array into a page is a bug that survives review; an exception does not. null and false print nothing. A string that is not valid UTF-8 prints with U+FFFD in place of the bad bytes rather than vanishing.

Testing

composer test

License

GPL-2.0-or-later