A lightweight full-stack component layer that doesn't dictate your front-end framework

Overview

Airwire

A lightweight full-stack component layer that doesn't dictate your front-end framework

Demo

Introduction

Airwire is a thin layer between your Laravel code and your JavaScript.

It lets you write Livewire-style OOP components like this:

class CreateUser extends Component
{
    #[Wired]
    public string $name = '';

    #[Wired]
    public string $email = '';

    #[Wired]
    public string $password = '';

    #[Wired]
    public string $password_confirmation = '';

    public function rules()
    {
        return [
            'name' => ['required', 'min:5', 'max:25', 'unique:users'],
            'email' => ['required', 'unique:users'],
            'password' => ['required', 'min:8', 'confirmed'],
        ];
    }

    #[Wired]
    public function submit(): User
    {
        $user = User::create($this->validated());

        $this->meta('notification', __('users.created', ['id' => $user->id, 'name' => $user->name]));

        $this->reset();

        return $user;
    }
}

Then, it generates a TypeScript definition like this:

interface CreateUser {
    name: string;
    email: string;
    password: string;
    password_confirmation: string;
    submit(): AirwirePromise<User>;
    errors: { ... }

    // ...
}

And Airwire will wire the two parts together. It's up to you what frontend framework you use (if any), Airwire will simply forward calls and sync state between the frontend and the backend.

The most basic use of Airwire would look like this:

let component = Airwire.component('create-user')

console.log(component.name); // your IDE knows that this is a string

component.name = 'foo';

component.errors; // { name: ['The name must be at least 10 characters.'] }

// No point in making three requests here, so let's defer the changes
component.deferred.name = 'foobar';
component.deferred.password = 'secret123';
component.deferred.password_confirmation = 'secret123';

// Watch all received responses
component.watch(response => {
    if (response.metadata.notification) {
        alert(response.metadata.notification)
    }
})

component.submit().then(user => {
    // TS knows the exact data structure of 'user'
    console.log(user.created_at);
})

Installation

Laravel 8 and PHP 8 are needed.

First install the package via composer:

composer require archtechx/airwire

Then go to your webpack.mix.js and register the watcher plugin. It will refresh the TypeScript definitions whenever you make a change to PHP code:

mix.webpackConfig({
    plugins: [
        new (require('./vendor/archtechx/airwire/resources/js/AirwireWatcher'))(require('chokidar')),
    ],
})

Next, generate the initial TS files:

php artisan airwire:generate

This will create airwire.ts and airwired.d.ts. Open your app.ts and import the former:

import Airwire from './airwire'

If you have an app.js file instead of an app.ts file, change the file suffix and update your webpack.mix.js file:

- mix.js('resources/js/app.js', 'public/js')
+ mix.ts('resources/js/app.ts', 'public/js')

If you're using TypeScript for the first time, you'll also need a tsconfig.json file in the the root of your project. You can use this one to get started:

{
  "compilerOptions": {
    "target": "es2017",
    "strict": true,
    "module": "es2015",
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "sourceMap": true,
    "skipLibCheck": true
  },
  "include": ["resources/js/**/*"]
}

And that's all! Airwire is fully installed.

PHP components

Creating components

To create a component run the php artisan airwire:component command.

php artisan airwire:component CreateUser

The command in the example will create a file in app/Airwire/CreateUser.php.

Next, register it in your AppServiceProvider:

// boot()

Airwire::component('create-user', CreateUser::class);

Wired properties and methods

Component properties and methods will be shared with the frontend if they use the #[Wired] attribute (in contrast to Livewire, where public visibility is used for this).

This means that your components can use properties (even public) just fine, and they won't be shared with the frontend until you explicitly add this attribute.

class CreateTeam extends Component
{
    #[Wired]
    public string $name; // Shared

    public string $owner; // Not shared

    public function hydrate()
    {
        $this->owner = auth()->id();
    }
}

Lifecycle hooks

As showed in the example above, Airwire has useful lifecycle hooks:

public function hydrate()
{
    // Executed on each request, before any changes & calls are made
}

public function dehydrate()
{
    // Executed when serving a response, before things like validation errors are serialized into array metadata
}

public function updating(string $property, mixed $value): bool
{
    return false; // disallow this state change
}

public function updatingFoo(mixed $value): bool
{
    return true; // allow this state change
}

public function updated(string $property, mixed $value): void
{
    // execute side effects as a result of a state change
}

public function updatedFoo(mixed $value): void
{
    // execute side effects as a result of a state change
}

public function changed(array $changes): void
{
    // execute side effects $changes has a list of properties that were changed
    // i.e. passed validation and updating() hooks
}

Validation

Airwire components use strict validation by default. This means that no calls can be made if the provided data is invalid.

To disable strict validation, set this property to false:

public bool $strictValidation = false;

Note that disabling strict validation means that you're fully responsible for validating all incoming input before making any potentially dangerous calls, such as database queries.

public array $rules = [
    'name' => ['required', 'string', 'max:100'],
];

// or ...
public function rules()
{
    return [ ... ];
}

public function messages()
{
    return [ ... ];
}

public function attributes()
{
    return [ ... ];
}

Custom types

Airwire supports custom DTOs. Simply tell it how to decode (incoming requests) and encode (outgoing responses) the data:

Airwire::typeTransformer(
    type: MyDTO::class,
    decode: fn (array $data) => new MyDTO($data['foo'], $data['abc']),
    encode: fn (MyDTO $dto) => ['foo' => $dto->foo, 'abc' => $dto->abc],
);

This doesn't require changes to the DTO class, and it works with any classes that extend the class.

Models

A type transformer for models is included by default. It uses the toArray() method to generate a JSON-friendly representation of the model (which means that things like $hidden are respected).

It supports converting received IDs to model instances:

// received: '3'
public User $user;

Converting arrays/objects to unsaved instances:

// received: ['name' => 'Try Airwire on a new project', 'priority' => 'highest']
public function addTask(Task $task)
{
    $task->save();
}

Converting properties/return values to arrays:

public User $user;
// response: {"name": "John Doe", "email": "[email protected]", ... }

public find(string $id): Response
{
    return User::find($id);
}
// same response as the property

If you wish to have even more control over how the data should be encoded, on a property-by-property basis, you can add a Decoded attribute. This can be useful for returning the id of a model, even if a property holds its instance:

#[Wired] #[Encode(method: 'getKey')]
public User $user; // returns '3'

#[Wired] #[Encode(property: 'slug')]
public Post $post; // returns 'introducing-airwire'

#[Wired] #[Encode(function: 'generateHashid')]
public Post $post; // returns the value of generateHashid($post)

Default values

You can specify default values for properties that can't have them specified directly in the class:

#[Wired(default: [])]
public Collection $results;

These values will be part of the generated JS files, which means that components will have correct initial state even if they're initialized purely on the frontend, before making a single request to the server.

Readonly values

Properties can also be readonly. This tells the frontend not to send them to the backend in request data.

A good use case for readonly properties is data that's only written by the server, e.g. query results:

// Search/Filter component

#[Wired(readonly: true, default: [])]
public Collection $results;

Mounting components

Components can have a mount() method, which returns initial state. This state is not accessible when the component is instantiated on the frontend (unlike default values of properties), so the component requests the data from the server.

A good use case for mount() is <select> options:

public function mount()
{
    return [
        'users' => User::all()->toArray(),
    ]
}

Mount data is often readonly, so the method supports returning values that will be added to the frontend component's readonly data:

public function mount()
{
    return [
        'readonly' => [
            'users' => User::all()->toArray(),
        ],
    ];
}

Metadata

You can also add metadata to Airwire responses:

public function save(User $user): User
{
    $this->validate($user->getAttributes());

    if ($user->save()) {
        $this->metadata('The user was saved with an id of ' . $user->id);
    } else {
        throw Exception("The user couldn't be created.");
    }
}

This metadata will be accessible to response watchers which are documented in the next section.

Frontend

Airwire provides several helpers on the frontend.

Global watcher

All responses can be watched on the frontend. This is useful for displaying notifications and rendering exceptions.

// Component-specific
component.watch(response => {
    // ...
});

// Global
Airwire.watch(response => {
    // response.data

    if (response.metadata.notification) {
        notify(response.metadata.notification)
    }

    if (response.metadata.errors) {
        notify('You entered invalid data.', { color: 'red' })
    }
}, exception => {
    alert(exception)
})

Reactive helper

Airwire lets you specify a helper for creating singleton proxies of components. They are used for integrating with frontend frameworks.

For example, integrating with Vue is as easy as:

import { reactive } from 'vue'

Airwire.reactive = reactive

Integrating with Vue.js

As mentioned above, you can integrate Airwire with Vue using a single line of code.

If you'd also like a this.$airwire helper (to avoid having to use window.Airwire), you can use our Vue plugin. Here's how an example app.ts might look like:

import Airwire from './airwire';

import { createApp, reactive } from 'vue';

createApp(require('./components/Main.vue').default)
    .use(Airwire.plugin('vue')(reactive))
    .mount('#app')

declare module 'vue' {
    export interface ComponentCustomProperties {
        $airwire: typeof window.Airwire
    }
}
data() {
    return {
        component: this.$airwire.component('create-user', {
            name: 'John Doe',
        }),
    }
},

Integrating with Alpine.js

Note: The Alpine integration hasn't been tested, but we expect it to work correctly. We'll be reimplementing the Vue demo in Alpine soon.

Alpine doesn't have a reactive() helper like Vue, so we created it.

There's one caveat: it's not global, but rather component-specific. It works with a list of components to update when the data mutates.

For that reason, you'd need to pass the reactive helper inside the component:

<div x-data="{
    component: Airwire.component('create-user', {
        name: 'John Doe',
    }, $reactive)
}"></div>

To simplify that, you may use our Airwire plugin which provides an $airwire helper:

<div x-data="{
    component: $airwire('create-user', {
        name: 'John Doe',
    })
}"></div>

To use the plugin, use this call before importing Alpine:

Airwire.plugin('alpine')()

Testing

Airwire components are fully testable using fluent syntax:

// Assertions against responses use send()
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
});

// Assertions against component state use hydrate()
test('properties are shared only if they have the Wired attribute', function () {
    expect(TestComponent::test()
        ->state(['foo' => 'abc', 'bar' => 'xyz'])
        ->hydrate()->bar
    )->toBe('xyz'); // foo is not Wired
});

You can look at the package's tests to see real-world examples.

Protocol spec

Airwire components aren't signed or fingerprinted in any way. They're completely stateless just like a REST API, which allows for instantiation from the frontend. This is in contrast to Livewire which doesn't allow any direct state changes — they all have to be "approved" and signed by the backend.

The best way to think about Airwire is simply an OOP wrapper around a REST API. Rather than writing low-level controllers and routes, you write expressive object-oriented components.

Request

{
    "state": {
        "foo": "abcdef"
    },
    "changes": {
        "foo": "bar"
    },
    "calls": {
        "save": [
            {
                "name": "Example task",
                "priority": "highest"
            }
        ]
    }
}

Response

{
    "data": {
        "foo": "abcdef"
    },
    "metadata": {
        "errors": {
            "foo": [
                "The name must be at least 10 characters."
            ]
        },
        "exceptions": {
            "save": "Insufficient permissions."
        }
    }
}

State

The state refers to the old state, before any changes are made. The difference isn't big, since Airwire doesn't blindly trust the state, but it is separated from changes in the request.

One use of this are the updating, updated, and changed lifecycle hooks.

Changes

If the change is not allowed, Airwire will silently fail and simply exclude the change from the request.

Calls

Calls are a key-value pair of methods and their arguments.

If the execution is not allowed, Airwire will silently fail and simply exclude the call from the request.

If the execution results in an exception, Airwire will also add methodName: { exception object } to the exceptions part of the metadata.

Exceptions have a complete type definition in TypeScript.

Validation

Validation is executed on the combination of the current state and the new changes.

Properties that failed validation will have an array of error strings in the errors object of the metadata.

Compared to other solutions

Due to simply being a REST API layer between JavaScript code and a PHP file, Airwire doesn't have to be used instead of other libraries. You can use it with anything else.

Still, let's compare it with other libraries to understand when each solution works the best.

Livewire

Livewire is specifically for returning HTML responses generated using Blade.

Most of our API is inspired by Livewire, with a few minor improvements (such as the use of PHP attributes) that were found as a result of using Livewire.

The best way to think about Livewire and Airwire is that Livewire supports Blade (purely server-rendered), whereas Airwire supports JavaScript (purely frontend-rendered).

Neither one has the ability to support the other approach, so the main deciding factor is what you're using for templating.

(This comparison is putting aside all of the ecosystem differences; it only looks at the tech.)

Inertia.js

Inertia is best thought of as an alternative router for Vue/React/etc. There is some similarity in how Airwire and Inertia are used for a couple of use cases, but for the most part they're very different, since Inertia depends on visits, whereas Airwire has no concept of visits or routing.

Inertia and Airwire pair well for specific UI components — say that you use Inertia for most things on your frontend, but then you want to build a really dynamic component that sends a lot of requests to the backend (e.g. due to real-time input validation). You could simply install Airwire and use it for that one component, while using Inertia for everything else.

Comments
  • First installation does not compile until you create at least one component

    First installation does not compile until you create at least one component

    Without having at least one airwire component you can't compile your assets, I struggled almost 4 days until I decided to try to create a component first then I was able to compile my assets

    opened by donmbelembe 3
  • Build A Manifest File Instead Of Manual Component Registration

    Build A Manifest File Instead Of Manual Component Registration

    Really awesome project, can't wait to try this out in a more official capacity. Thank you for creating it.

    But I did notice one thing that could be improved upon.

    I mentioned in another comment I wrote a Laravel package for Svelte that has many similar problems to solve. My package compiles each Svelte component in your project into bite sized js file, and then loads that file automatically if you use the tag in blade.

    So we need to map Blade HTML tags to these compiled JS files. You have to do the same for Blade tags and PHP files, as does Livewire.

    I looked at several other projects as inspiration:

    • Spatie's BladeX package (which serves as the foundation for Laravel's Blade Components)
    • Laravel's Blade Components
    • Livewire

    There are three main approaches here:

    1. Having the user define these mappings in a ServiceProvider

    • The approach Spatie Blade X and Airwire took
    • The easiest to implement, yet the most annoying for the user.

    2. Use a Naming Convention, Standard Locations, Etc.

    • Laravel Blade Components basically took Spatie BladeX, but then added a naming convention on top
    • By prefixing an x- in front of the component name, and doing some Studly/Camel-case conversion, Laravel knows to check the /resources/views/components folder

    3. Build A Manifest File Automatically

    • This is the approach Livewire takes, and the approach I stole for my package
    • The user doesn't have to define the components in a ServiceProvider
    • The package generates a manifest file that loads the component-to-tag mappings
    • Check out bootstrap/cache/livewire-components.php

    I would love to see Airwire use the manifest approach outlined above and remove that annoying middle step for the user.

    Happy to help implement/submit a PR.

    Have you considered this at all? See any downsides?

    opened by nickpoulos 0
  • Question - How to handle auth and policy related validations?

    Question - How to handle auth and policy related validations?

    Hi there!

    When I stumbled across Airwire, my developer-heart made a little jump of joy. This seems to be the library I've been thinking of so many times... Inertia.js seems to be too much, Livewire 'forces' me to use alpine (I know this aint completely true, but nevermind), but this library just does the wiring, and it lets me be in control of my js side and backend side. Awesome!

    My current api-like controllers which can be seen as the soon-deprecated-pre-airwire-component-classes, always handle some business-like validation, and auth related validation. When something goes wrong on that endpoint, I return a 400 error or something, and that's it.

    But.... How do we handle such cases in Airwire? To make things more specific:

    1. How do we handle Laravels policy validation in Airwire components?
    2. How do we handle authentication related validation in Airwire components?
    3. How do we handle custom business logic validation in Airwire components, and what should be returned when something goes wrong (the 400 status code)?

    Thank you for reading and commenting :)

    Bob

    opened by bobmulder 7
  • Airwire with NextJS/React

    Airwire with NextJS/React

    Greetings,

    I love the idea of this being a REST API Wrapper component based. Is It possible to use something like this with NextJS? Where the backend and the frontend might be in different servers?

    opened by mackensiealvarezz 4
  • Can you use Airwire with Svelte?

    Can you use Airwire with Svelte?

    Hi,

    I'm a big fan of Svelte, and Airwire looks interesting to me.

    I'm not sure if I can use Airwire with Svelte, because Svelte outputs vanilla js.

    Svelte is a radical new approach to building user interfaces. Whereas traditional frameworks like React and Vue do the bulk of their work in the browser, Svelte shifts that work into a compile step that happens when you build your app.

    So, is it possible to combine those two?

    Thanks! Jeroen

    enhancement 
    opened by jeroenvanrensen 2
Releases(v0.1.4)
Owner
ARCHTECH
Meticulously architected web applications.
ARCHTECH
Laravel 5 boilerplate with front-end and back-end support

Laravel 5 Boilerplate with Skeleton framework Skeleton (as of now) Laravel 5 framework application. Application includes and/or are currently being us

Robert Hurd 40 Sep 17, 2021
A PHP notebook application build with PHP Symfony as back-end API and VueJS/Vuetify front-end.

PHPersonal Notes ?? - BETA RELEASE PHPersonal notes is an application to store your personal notes! PHPersonalnotes is build with Symfony/VueJS/Vuetif

Robert van Lienden 3 Feb 22, 2022
A Laravel dashboard front-end scaffolding preset for Tailwind CSS - Support RTL out of the box.

?? Laravel tailwind css dashboard preset A Laravel dashboard front-end scaffolding preset for Tailwind CSS - Support RTL out of the box. Usage Fresh i

Miaababikir 343 Dec 7, 2022
Very simple CRUD project, written in pure php. Designed as framework-agnostic as possible, and with basically no stack overflow if you can believe that

briefly simple CRUD pure php project for self improvement I try to make it purely in github - not only code, but any documentation (wiki), tasks (issu

Michał Jędrasiak 1 Jan 23, 2022
Web Sekolah yang dibuat diatas CMS Popoji dengan base Laravel 6. Web Sekolah ini sudah diintegrasikan dengan template semesta-front.

Web Sekolah yang dibuat diatas CMS Popoji dengan base Laravel 6. Web Sekolah ini sudah diintegrasikan dengan template semesta-front.

Muhamad Ramdani Hidayatullah 1 Feb 6, 2022
This is a simple example project demonstrating the Lupus Nuxt.js Drupal Stack.

Lupus Nuxt.js Drupal Stack - Example project This is a simple example project demonstrating the Lupus Nuxt.js Drupal Stack. Introduction Please refer

drunomics 11 Dec 28, 2022
Webird was created to merge the latest PHP and Node.js innovations into a single application stack.

Webird full application stack Webird was created to merge the latest PHP and Node.js innovations into a single application stack. The PHP foundation i

Perch Labs 101 Oct 7, 2022
Build a full-featured administrative interface in ten minutes

⛵ laravel-admin is administrative interface builder for laravel which can help you build CRUD backends just with few lines of code. Documentation | 中文

Song 10.7k Dec 30, 2022
A full-featured Webpack + vue-loader setup with hot reload, linting, testing & css extraction.

#Vue-Cli Template for Larvel + Webpack + Hotreload (HMR) I had a really tough time getting my workflow rocking between Laravel and VueJS projects. I f

Gary Williams 73 Nov 29, 2022
This is a laravel Auth Starter Kit, with full user/admin authentication with both session and token auth

About Auth Starter It's a Laravel 8 authentication markdown that will help you to understand and grasp all the underlying functionality for Session an

Sami Alateya 10 Aug 3, 2022
Template for repository helper, library - Basic, Simple and Lightweight

Template start helper, library Template for repository helper, library - Basic, Simple and Lightweight Use this Template First, you can Use this templ

Hung Nguyen 2 Jun 17, 2022
Allows to connect your `Laravel` Framework translation files with `Vue`.

Laravel Vue i18n laravel-vue-i18n is a Vue3 plugin that allows to connect your Laravel Framework JSON translation files with Vue. It uses the same log

Francisco Madeira 361 Jan 9, 2023
Laravel CRUD Generator This Generator package provides various generators like CRUD, API, Controller, Model, Migration, View for your painless development of your applications.

Laravel CRUD Generator This Generator package provides various generators like CRUD, API, Controller, Model, Migration, View for your painless develop

AppzCoder 1.3k Jan 2, 2023
Someline Starter is a PHP framework for quick building Web Apps and Restful APIs, with modern PHP design pattern foundation.

Someline Starter PHP Framework Tested and used in production by Someline Inc. Someline Starter is a PHP framework for quick building Web Apps and Rest

Someline 844 Nov 17, 2022
PHP Framework for building scalable API's on top of Laravel.

Apiato Build scalable API's faster | With PHP 7.2.5 and Laravel 7.0 Apiato is a framework for building scalable and testable API-Centric Applications

Apiato 2.8k Dec 29, 2022
Rest API boilerplate for Lumen micro-framework.

REST API with Lumen 5.5 A RESTful API boilerplate for Lumen micro-framework. Features included: Users Resource OAuth2 Authentication using Laravel Pas

Hasan Hasibul 484 Sep 16, 2022
A PHP framework for console artisans

This is a community project and not an official Laravel one Laravel Zero was created by, and is maintained by Nuno Maduro, and is a micro-framework th

Laravel Zero 3.2k Dec 28, 2022
PHP Framework for building scalable API's on top of Laravel.

Apiato Build scalable API's faster | With PHP 7.2.5 and Laravel 7.0 Apiato is a framework for building scalable and testable API-Centric Applications

Apiato 2.8k Dec 31, 2022
An account management Panel based on Laravel7 framework. Include multiple payment, account management, system caching, admin notification, products models, and more.

ProxyPanel 简体中文 Support but not limited to: Shadowsocks,ShadowsocksR,ShadowsocksRR,V2Ray,Trojan,VNET Demo Demo will always on dev/latest code, rather

null 17 Sep 3, 2022