Hiraeth 3.0 promotes a view-first approach.
We contrast this to what we call a “controller-first” approach which has dominated back-end web development since the dawn of Ruby on Rails. While technically the controller is not first (just as technically the view is not first), it is the request specific entry-point for most modern web applications. Middleware, by contrast, is generally not request specific and may handle large groupings of URLs requested or even all URLs requested. The front-controller, while ever-present in any modern back-end application, is too minimal to mention.
The view-first approach does not entail violating separation of concerns. Practically, view-first means:
- The absolute first lines of code you write must be able to show you something in the client.
- The view determines its control logic as opposed to a controller determining the view / template. This means:
- Control logic is optional.
- Control logic is re-usable and stackable.
- Partial views are request-ready and able to be rendered independently
How to Build View-First
Hiraeth provides a lot of view-first functionality through the hiraeth/twig
and hireath/twig-tags
packages. The hiraeth/actions
also provides the requisite action()
function to enable Twig templates and views to obtain context by calling their controllers instead of controllers loading them.
The mock()
filter and function allows you to “mock” both the context and the data that may be returned from a controller, allowing you to basically call a non-existent controller and still have data to work with as a fallback.
This allows you to rapidly prototype application interfaces with little more than Twig and HTML:
{% do action('Users:Get') | mock(
{
user: mock('@mocks/users/current.json')
}
) %}
<h1>Edit User</h1>
<h2>{{ user.username }}</h2>
<x::form
method="patch"
action="{{ route('/users/{id}/form', {id: user.id}) }}"
>
{% include '@pages/users/detail/%form.html' %}
</x::form>
Breaking Down the Example
As this example includes a number of different features, let’s take a moment to break them down one by one into logic chunks.
Calling and Mocking Actions (Controller Logic)
In the view-first approach, views / templates call their control logic. This includes partials, which means you might actually have multiple pieces of control logic called when rendering a given view. If any one of them fails, for example, if the data they should provide cannot be found, they can interrupt the request and return other responses.
Since we’re delaying our controller logic as long as possible, we can also “mock” the data that would normally be provided by the controller. This can enable more rapid development and prototyping and/or act as a “playground” for data structures that can later inform data modeling and service responses.
{% do action('Users:Get') | mock(
{
user: mock('@mocks/users/current.twig')
}
) %}
The action() Function
The action()
function, in an of itself (ignoring the mock()
filter) calls a requested action (single function controller). The action can do normal controller things like a return a 404
response, redirect, perform auth checks, load data from a database or update it, as well as all the normal service calls a controller does. Upon success, it is expected to return a simple associative array of the data that it provides, In turn, that data is loaded into the Twig template context to be directly accessible as variables. :
namespace Users;
class Get extends \AbstractAction
{
public function __construct(
protected Repository $users
) {}
public function __invoke($id)
{
$user = $this->users->find($id);
if (!$user) {
return $this->response(404);
}
return [
'user' => $user
]
}
}
In the above example, the action()
function will automatically take the defined parameters.id
variable (part of the pages routing system) and inject it when calling the __invoke()
method. Similarly, the action itself will be resolved through the dependency injector and have its constructor arguments provided to it.
The mock() Filter
It may seem like we’ve already gone back to our controller-first approach. However, if the class cannot be found, the action()
method will return a callback which, in turn, will execute if passed to the mock()
filter. When the application is in debugging mode, this will resolve an empty array in place of the returned context, enabling the mock()
filter to fill in.
From the Twig side of things. The mock()
filter takes only the single argument which is the data to be mocked when this occurs, which it loads into context. In our cases, we’re mocking the returned user. The user data, in turn, uses the mock()
function to load a file that can look something like this:
{% do mock({
id: 1,
username: 'CurrentUser',
friends: [
mock('@mocks/users/sally.twig'),
mock('@mocks/users/bob.twig')
]
}) %}
This provides the requisite properties on the mocked result, including sub-mocks for friends (which can recursively reference this mock).
Using action()
with the mock()
filter and mock()
function can provide a simple way (in debug mode), to rapidly develop rich, complex data-driven user interfaces, without needing an actual data source or taking time away to jump between your controller logic and your prototype template.
Using the context
Now that our action is either available or successfully mocked and is providing the requisite context, we can simply use it:
<h1>Edit User</h1>
<h2>{{ user.username }}</h2>
There’s nothing too surprising here if you’re already familiar with Twig. That said, it’s important to note that this approach is enabled by Twig insofar as user.username
can be called without any care as to whether or not the username
property of the user is a property, a method, etc… so the actual data types returned from your actions are flexible. Classes with getters (even magic methods), associative arrays, objects with public properties, etc… all fine.
Using Components
<x::form
method="patch"
action="{{ route('/users/{id}/form', {id: user.id}) }}"
>
This example assumes that <x::form>
is represented by a component in the resources/tags/form.html
file. Components are provided by the hiraeth/twig-tags
package and, as you can see basically take on a custom HTML tag using the :
namespace operator. Double colons (::
) represent components in the root of the resources/tags
folder, while a single one would be a namespaced tag. For example <x:form:string />
might represent a string input.
Components are very similar to a Twig include, except their attributes are passed as variables into the component partials’ context. They also include a ctx
variable which contains the broader context cratated by parent tags. A hypotehtical form component might look like:
{% set method = method ?? 'get' %}
{% set action = action ?? request.uri.pathname %}
<form method="{{ method }}" action="{{ action }}">
{{ children|raw }}
</form>
Although this is a simple and contrived example, you can imagine how a component could easily abstract a much complex and larger subset of HTML. Combining components with Tailwind or other utility CSS frameworks makes for very powerful results.
In this case, however, the bulk of the work is in the children. You can see we include all children that were provided from the calling context in a special children
variable, which we can dump with the raw
filter to simply pass along. It’s also possible to selectively grab children since children
is technically an array of all nodes in the original context.
That means that we can just as easily nest components or go back to our standard Twig and use an include
, like we did.
Nesting Components and Includes
Whether or not you nest additonal components or use Twig’s built in include doesn’t matter. The former makes sense when what’s being included is not, itself re-usable, while the latter is often good when it may stretch across various other templates. In this case, we’re dealing with editing a user, but the same form elements could be used to create a user.
{% include '@pages/users/detail/%form.html' %}
What’s most interesting here, and as we’ll show, is that whatever the context, additional actions (controller logic) can be performed. View-first means control logic is stackable. Let’s imagine the above included file contains the following:
{% do action('Users:Mutate') | mock(
{
user: mock('@mocks/user/current')
}
) %}
<x:: model={% v: user %}>
<x:form:string name="username" />
</x::>
Yes… we’re calling and mocking another action. No… this is not particularly problematic from a performance standpoint (at least not if you’re using a decent ORM). Yes… it’s actually a better separation of concerns. No… this is not just turning controllers into services.
In all cases, control logic is going to call some set of services either explicitly or implicitly. In an example such as this, it will again look up our user (identity map would prevent a second query, return same user object as before), check with authorization service to ensure the authorized user can mutate the requested user, call some sort of user service to update the user (if authorized and if the request method is appropriate), and return the modified user (or un-modified if we’re still just getting the user for mutating).
Practically, this means that the control logic follows the corresponding view / template for which it’s necessary 1-to-1, meaning you can drop the this form into another context with no change to the initiating controller. Each view / template has its discrete control logic. It also means that your user’s profile page and your admin’s CRUD user management page can effectively use the same control logic but present different fields to the user.
With that out of the way, let’s cover the more interesting bits.
Fragment Components
While not technically a compnent the <x::>
tag can be used as a fragment. This means it has no HTML representation of its own and simply provides context or proxies attributes to its children. In our case we call it as such:
<x:: model={% v: user %}>
This is going to make ctx.model
available for all children and children of children (until overloaded). So, your resources/tags/form/string.html
component may look something like this:
{% set label = label ?? name|title %}
{% set value = request.parsedBody[name] ?? ctx.model[name] ?? value ?? '' %}
<x:form:label>{{ label }}</x:form:label>
<input type="text" name="{{ name }}" value="{{ value }}" />
It’s also the first time we’re seeing the “value syntax” which, unlike a standard attribute passes the value directly down to the child compoment. In this case, the model is the full rich user object, not a string representation.
It’s also possible to use fragments to merge or add attributes with multiple children using passthrough attributes, which end with the :
. If a child already has the attribute defined, the contents of these attributes will be appended with a space (most useful for classes), or if they don’t have it defined, it will be added entirely:
<x:: class:="mb-2" data-attr="value">
<p class="text-red-700">
I get a class of "mb-2" merged with my other classes,
and a "data-attr" attribute.
</p>
<p>I also get a class of "mb-2" and a "data-attr" attribute.</p>
</x::>
Conclusion
View-first is a very different way of building websites than what traditional back-end MVC frameworks are used to. We here at Hiraeth Enterprises Incorporated LLC (just kidding) are still investigating and playing around with this approach. The development of our HTMX and AlpineJS driven component-based application framework (codenamed Velocity) seeks to extend these possibilities even further.
In fact, this entire site has now be re-written to use the Velocity specific and more general view-first approach. Furthermore, this approach developed out of real world use-cases and experiences. The hiraeth/twig-tags
package is nothing new, nor were action()
calls. The addition of new features, however, has solified this approach to new website and application builds. Combined with HTMX, AlpineJS, and Tailwind, we are developing more feature rich tools at a much faster rate, with no or very small build process, near complete backend and front-end integration, and the speed and traditional value proposition of hypermedia.