I'm very excited to announce the first release of Airwire.
Airwire is simple, yet extremely powerful tool for communicating between frontend JavaScript components and backend PHP code.
It can best be explained using a real-world example, so let's do just that.
Example: Create report form
Imagine a form for creating reports. It has three inputs: assignee, name, and category.
The requirements are simple: show validation in real-time, fetch the available assignees from the database, and clear the form after submitting.
In the Laravel world, there are three common ways to implement this:
Vue.js + JSON API
This approach requires doing everything manually. You'd write an endpoint for fetching the assignees, an endpoint for validating the input, and finally an endpoint for submitting the form.
You'd v-model
inputs to your Vue data and add watchers to send requests when the data changes.
This is the most low-level approach (putting no-framework solutions aside) and it's essentially you telling the app how to do things.
Inertia.js
Inertia is a more modern solution that aims to abstract some of these low-level things, and lets you write code that's less concerned with exactly how the data is sent. You make vists to routes and the controllers return Inertia responses.
You still need to watch the user input and send requests to different actions, but it's a bit better experience.
Livewire
Finally, Livewire is a very interesting solution to this problem. You write the template and bind inputs to the data model — same as the previous solutions. Then, you create a PHP component class that provides the validation rules and defines the interactions. The actual frontend part doesn’t concern itself with how to communicate with the backend.
Livewire specifies what is done, not how it is done. It’s a declarative solution, not a procedural one.
The only limitation is that it uses Blade templates, rather than JavaScript templates. (Limitation in some contexts, that is. Often times Blade templates are a massive benefit, but let's put that aside now).
The best of both worlds
These approaches can be combined into a solution that provides the best of both worlds — templates written in JavaScript and component logic written in PHP.
Welcome to Airwire: A lightweight full-stack component layer that doesn't dictate your front-end framework.
In Airwire, you create frontend components in any JavaScript framework (or without any framework at all — Airwire will still work), and you drop in a few lines of code that connect your code to the backend PHP component.
<template>
<form @submit.prevent="submit" :class="{'opacity-50': component.loading}">
<h2>Create Report</h2>
<form-group label="Assignee" :errors="component.errors.assignee">
<select class="form-input" v-model.lazy="component.assignee">
<option :value="user.id" v-for="user in component.users" :key="user.id">{{ user.name }}</option>
</select>
</form-group>
<form-group label="Name" :errors="component.errors.name">
<input type="text" class="form-input" v-model.lazy="component.name">
</form-group>
<form-group label="Category" :errors="component.errors.category">
<select class="form-input" v-model.lazy="component.category">
<option :value="category" v-for="category in categories" :key="category">{{ category }}</option>
</select>
</form-group>
<button type="submit">Create Report</button>
</form>
</template>
<script lang="ts">
export default defineComponent({
props: ['categories'],
data() {
return {
component: Airwire.component('create-report', {
name: 'Report ...',
}),
}
},
mounted() {
this.component.mount().then(data => {
this.component.defer(() => {
this.component.category = this.categories as any;
this.component.assignee = (Object.values(data.users)[0] as any).id as number;
});
})
},
methods: {
submit() {
this.component.create().then(_ => {
Airwire.refresh('report-filter');
});
}
}
})
// The markup is slightly simplified for clarity
</script>
We're using a couple of Airwire features here:
- We instantiate the component using the
Airwire.component()
helper, and we store it in the component data. - We bind inputs to
component.<property>
. When a property updates, Airwire sends a request to the server with the change and receives a response with new data. If the response changed another property's value, it would be immediately reflected. - We bind component errors to
<form-group>
components, which loop through them and display them below each input. - In
mounted()
— which is executed when the component is added to the DOM — we callmount()
on the component to fetch initial data. This responds with a list of available users (see the PHP code below). When we receive that response, we update the selected assignee to the first user, to make the input have a value, and we do the same with the category input. - Finally, we add a
submit()
method that calls thecreate()
method on the component. In thethen()
callback, we tell Airwire to refresh allreport-filter
components. Those components display a list of filtered reports, so the displayed data might become stale by adding a new report to the database.
For the PHP half, you create components similar to Livewire. There are a few extremely valuable improvements compared to Livewire as well, but we'll get to those a bit later.
The component for our example might look like this:
class CreateReport extends Component
{
#[Wired]
public string $name;
#[Wired]
public int $assignee;
#[Wired]
public int $category;
public function rules()
{
return [
'name' => ['required', 'min:10', 'max:35'],
'assignee' => ['required', 'exists:users,id'],
'category' => ['required', Rule::in([1, 2, 3])],
];
}
public function mount(): array
{
return [
'readonly' => [
'users' => User::all()->values()->toArray(),
],
];
}
#[Wired]
public function create(): Report
{
if ($found = Report::firstWhere('name', $this->name)) {
throw new Exception('Report with this name already exists. Please see report ' . $found->id);
}
$report = Report::create([
'name' => $this->name,
'assignee_id' => $this->assignee,
'category' => $this->category,
]);
$this->meta('notification', __('reports.created', ['id' => $report->id, 'name' => $report->name]));
$this->reset();
return $report;
}
}
To go through exactly what's happening there:
- We haven't explicitly disabled strict validation, which means that all input is validated automatically, and method calls won't be allowed until the entire state is perfectly valid.
- We define the properties for our state and we use the
#[Wired]
attribute to flag them as shared with the frontend. - In
rules()
we define the validation rules for our properties. Again, these will be executed on each request, before anything else, since strict validation is enabled by default. - The
mount()
method returns initial state in response to themount()
call from the frontend. In this case, it's a list of all available users, and it's part of the readonly state, which means that the frontend won't be sending it to the server on each request, since the data is considered static and will only be stored on the frontend. - In
create()
(again#[Wired]
to allow frontend calls) we handle the actual report creation.- We try to find a report with the same name. If it exists, we throw an exception that mentions the report's ID.
- We create the
$report
instance. - We add a backend language string (a huge benefit of backend-based components is access to all of these helpers) to the response metadata, under the
notification
key. - We reset the form.
- We return the
$report
model. Airwire will run->toArray()
on this model to return its JSON representation to the frontend. (This value is what will be passed to any.then()
callbacks.)
Global behavior
There are two interesting things happening in the create()
method of the component.
First, it can throw a custom exception. Second, it adds metadata to the server response.
You may have noticed that there's no code for handing those in the snippets above.
So where is that logic handled? It's handled globally, in app.js
.
There's nothing component-specific about notifications or exceptions, so app.js
is a great place for such logic.
Here's the full code:
Airwire.watch(response => {
if (response.metadata.notification) {
notify(response.metadata.notification);
}
}, exception => {
alert(exception.message);
})
Airwire.watch()
lets you inspect any incoming server response. The former callback is executed on successful requests, and the latter callback is executed on errors.
In our case, we check if the incoming response metadata has a notification
and if it does, we pass it to the notify()
method, which is a custom helper exposed by one of our Vue components.
If an exception is thrown, the second callback will alert()
the exception's message.
TypeScript
All of the above is great, but what assurance do we have on the frontend? How can we know what properties are available, what methods are available — what arguments they accept, what types they return — and similar things?
It's almost like we want a type definition of some sorts, for our frontend.
Aaand that's exactly what Airwire provides. Every component gets a perfect TypeScript definition.
interface CreateReport {
name: string;
assignee: number;
category: number;
create(): AirwirePromise<Report>;
mount(): AirwirePromise<any>;
errors: {
[key in keyof WiredProperties<CreateReport>]: string[];
}
// Slightly truncated
}
All properties are typed.
All method arguments and return values are typed.
All models are typed, including relations. The JSON representation, with types from your casts as well as automatically inferred types, is used to generate the type for each model.
1:1 IDE support for your properties and methods pic.twitter.com/xUcAnunio1
— Samuel Štancl (@samuelstancl) May 13, 2021
Type transformers
Models are great, but sometimes components go beyond them. Custom classes are used in all large applications, and this is a major limitation of most PHP component frameworks.
Luckily, Airwire lets you define how any type should be encoded and decoded between requests, even if you can't directly modify the class.
Airwire::typeTransformer(
type: CustomClass::class,
decode: fn (array $data) => new CustomClass($data['foo'], $data['abc']),
encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc],
);
Testing
Airwire components are fully testable using fluent syntax:
test('properties are shared only if they have the Wired attribute', function () {
expect(TestComponent::test()
->state(['foo' => 'abc', 'bar' => 'xyz'])
->send()
->data
)->toBe(['bar' => 'xyz']); // foo is not Wired
});
The Component::test()
method returns a RequestBuilder
instance that can be used to chain calls like ->state(['foo' => 'bar'])->change('foo', 'baz')->call('myMethod')
.
Then, by calling ->send()
, this can be converted to a TestResponse
which lets you access the returned data using e.g. ->get('foo')
or ->call('myMethod')
to get the value returned by myMethod
.
Ecosystem
As mentioned, any frontend framework is supported, and so is using Airwire without a framework. With that said, there are small tweaks you need to do to make Airwire work with your frontend framework.
And by a small tweak I mean one. One line of code:
import { reactive } from 'vue'
window.Airwire.reactive = reactive
The code above is all you need to do to make Airwire work with Vue, since it's built to leverage reactive proxies such as those provided by the Vue reactive()
helper.
Alpine doesn't have such a helper out of the box, so we built it.
We have a Vue.js demo right now, and shortly we'll rewrite it to Alpine and release it. The side-by-side comparison of Vue syntax and Alpine syntax with PHP Airwire components used for all of the business logic will be very interesting to see.
Roadmap
Airwire was just released as v0.1.0. This version is not considered production-ready, and is meant to get your feedback so that we can make sure everything is covered before tagging v1.0.0.
Aside from the core package, we also have a Vue plugin which allows for using Airwire like this.$airwire.component()
with full IDE support, and also auto-registers the reactive
helper.
Same thing will be done for Alpine, which will use $airwire('component-name', { data })
for initializing components.
If things go well, we expect to tag v1 within two weeks.