PHP

★ Execute Artisan commands on remote servers

[AdSense-A]

Using the newly released spatie/laravel-remote package, you can quickly execute Artisan commands on a remote server.

Here's an example that will clear the cache on the remote server.

php artisan remote cache:clear

In this blog post, I'd like to tell you all about it!

Using laravel-remote

You can install the package in any Laravel app using

composer require spatie/laravel-remote

No surprises there!

The package contains a remote command that will connect to your remote server and execute a given artisan command there.

By setting these environment variables in your .env file, the package knows where to connect to. Here's an example for a site hosted on Forge.

#in your .env file
REMOTE_HOST=example.com
REMOTE_USER=forge
REMOTE_PATH=/home/forge/example.com

With this in place, you can clear the cache on your server with:

php artisan remote cache:lear

Of course, you can use an Artisan command you'd like.

If you want to execute a bash command, use the --raw option.

Here we will get a list of files on the server.

php artisan remote ls --raw

You can define hosts in the config file. By default, the default host is used. To execute a command on another host, use the --host option.

php artisan remote cache:clear --host=my-other-host

Shorten the command using bash aliases

For many years I have been aliasing "php artisan" to "a". This allows me to execute any artisan command very quickly.

a clear:cache

It's a small improvement, but I'm sure that the alias has saved me quite some time summed up over all the years.

I've now added another alias, "ar," which maps to "php artisan remote".

ar clear:cache

It's pretty cool with how little efforts this allows me to execute commands on a remote server.

How it works under the hood

When executing remote, the package will connect to your server via SSH, cd to your app's directory, and execute the command.

In this streaming session, you can see me build up the package from scratch.

In closing

I think spatie/laravel-remote can save you a lot of time if you regularly want to execute commands on a remote server.

Be sure to take a look at this list of packages our team has created previously.

(more…)

By , ago
PHP

★ Introducing monthly playlists from team Spatie

[AdSense-A]

At Spatie, each one of our team members loves music. Scattered across our office are a couple of HomePods. Everyone in our team is free to stream his favourite music for others to hear (of course at an acceptable volume so everyone can still work).

This is a great way to discover music. In my mind, any automated algorithm that picks music for you is trumped what your friends and peers suggest to you.

Because of the pandemic, this way of sharing music with each other was lost. That's why our team will from now on create monthly playlists. The process is easy: every month we will choose a theme for the playlist and each team member picks two or three tracks.

The first theme is "Late Night Something" (it's not "late night coding" because not everyone on our team codes.

cover

Here's our playlist on Apple Music. And here is the same playlist on Spotify.

Here's at the Spotify embed so you can listed from your browser too.

I hope you'll enjoy these tracks as much as we do! I'm already looking forward to next month's playlist.

(more…)

By , ago
PHP

★ Debug apps running on remote servers using Ray

[AdSense-A]

Earlier this year, we released Ray, a desktop app that allows you to debug faster. Instead of dumping values to the browser or console, Ray allows you to display debugging information beautifully in a dedicated window.

Since launch, Ray helps you debug local projects. Today, we're adding the most requested feature to Ray: the ability to connect to remote servers. All output of the ray() call, will be sent securely from your remote server to the local app via SSH.

Using this feature, you can quickly investigate problems on your production servers that you are unable to recreate locally.

In this short video you'll see a quick demo!

If you're not familiar with Ray yet, read this blogpost on why we created Ray and how you can use it. Do also check out the docs and promotional site.

Since launch, our team has added a lot of features. If you have any feature request or questions, let us now via the discussions tab on spatie/ray.

(more…)

By , ago
PHP

★ How to customize Jetstream and Laravel Spark

[AdSense-A]

Three years ago, my buddy Mattias and I launched Oh Dear, a polished service to monitor websites.

Unlike most similar services, Oh Dear does not only look at your homepage but also crawl your entire sites and report any problems such as broken links and mixed content. In addition to that, Oh Dear can monitor SSL certificates and scheduled jobs, and it also provides status pages like this one.

Oh Dear is a Laravel application. When we launched, we used what is now called the classic version Laravel Spark to take care of team management and billing. A few weeks ago, the Laravel team launched a modern version of Spark with a lighter footprint.

screenshot

In this blog post, I'd like to share how we moved from classic to modern Spark for billing and Jetstream for team management. You'll also learn various ways how to customize Spark to your liking.

Why we upgraded from classic Spark to modern Spark & JetStream

Classic Spark was, at the time when it was launched, a very convenient way of bootstrapping a SaaS. It provided billing, subscription and team management, and a few extras such as notifications and the ability to impersonate users.

screenshot

It's likely that if Horizon or classic Spark didn't exist, Oh Dear probably wouldn't have happened. The existence of both these packages gave Mattias and me room to work on the core features of our app. It prevented us from losing ourselves in the intricacies of queuing, billing and subscription handling.

Besides backend functionality, classic Spark also included a UI to handle team, billing and subscription management. The UI was built entirely using Vue components. For people that at the time were already familiar with Vue, this was great! But a part of the community, Vue hadn't "clicked" yet, and Blade was the preferred way to build screens.

Even though at the time, both Mattias and I didn't have too much experience with Vue, we went ahead with using Spark because it could save us so much time. For us, the downside was that customizing the UI and certain parts of the default Spark behaviour was hard to do. Sure, you could skip using the provided Vue components and only use the backend of Spark (that what we did when building Flare), but it's time-consuming to do so.

If you want to use team billing in classic Spark, the obvious route was to use the Spark provided User and Team Eloquent models. Again, going this route saves you a lot of time, but I've always been aware that Spark was very tightly coupled to our Laravel app. I considered this as a risk because moving away from Spark could potentially prove very hard.

When we fast forward from the time that Spark classic was launched until now, we see that a lot has changed. Using Blade with Vue components isn't the predominantly way of handling the front end of a Laravel app anymore. Lots of options have emerged. I feel that the love for React and TypeScript has grown considerably over the last few years in our community. Both Inertia and Livewire are considered wonderful production-ready alternatives to sprinkling Vue components in Blade views.

In the past few years, we've been using Livewire more and more in Oh Dear's UI. Replacing WebSockets with Livewire vastly simplified our codebase. Most of our forms that need a little bit of extra behaviour were also refactored to Livewire. Because we were so happy with Livewire, there was a wish to replace all of Spark's Vue powered user screens with Livewire equivalents. We didn't do this as we wanted to invest time in such drastic customization. We decided to just live with the fact that some parts of our app were handled by Livewire and others by Vue.

A few months ago, Laravel Jetstream was released. This first party package provides an implementation for all auth related things in a Laravel Ray app: login, registration, email verification, two-factor authentication, session management, and team management. JetStream also comes with a UI. There are two flavours available: Intertia+Vue and Blade+Livewire.

Of course, I was thrilled with the fact that Jetstream had that Blade+Livewire flavour. I didn't immediately move to using those screens as Jetstream uses its own Team base model, which slightly differs from classic Spark's. And meanwhile, the next generation of Spark was already announced.

The footprint of the newest version of Spark, which was launched a few weeks ago, is much smaller than classic Spark. The new version of Spark consists only of a separate billing portal where subscriptions are handled. Which model gets billed is configurable. This means that Spark's new version doesn't reach as deep into your app as the classic version. Because the UI is separate, you aren't forced to use any particular front end stack.

At Oh Dear, we're moving from classic Spark to the modern Spark/JetStream because the pieces fell in place:

  • JetStream provides Livewire powered user and team management and UI
  • modern Spark can use a configurable billable (in our case, Jetstream's Team model).

This allows us to remove the Vue powered screens, make our app more maintainable. It's also a good thing that our Laravel app can now use the latest and greatest things offered by the Laravel ecosystem.

Exploring the differences between classic Spark and modern Spark/JetStream

This post doesn't aim to give you a step by step formula to migrate from classic Spark to new Spark, as each spark app is slightly different.

In general, these are the steps that I took:

  1. Create a separate branch of the oh dear repo, so I could still refer to the master branch to see how things worked
  2. Delete all publish spark Vue components
  3. Remove the spark-aurelius dependency in composer.json and remove any service providers related to it
  4. Install JetStream and laravel/spark-stripe
  5. Migrate the database to the new format
  6. Fix all problems that might arise from this.

Again, these are just high-level steps. I will however explain how I went about knowing which migrations I had to write.

To explore this, I needed to compare how the live app writes users, teams, and subscriptions with how the new Spark/Jetstream are storing this data. I created a default Laravel application and installed both Jetstream and Spark into it.

You can find that application in this repo on GitHub. To install this app, you'll need to have a Spark license.

With the application installed, I created a couple of teams with users and looked at how they are stored in the database.

To know how Spark stores subscriptions, I defined a product in my Stripe account's test environment and added that product in Spark's configuration file.

When a subscription is made, Stripe will send a confirmation to the Laravel app via a webhook. To handle incoming webhooks easily, you can use the Stripe CLI.

You can install the CLI using:

brew install stripe/stripe-cli/stripe

In the composer.json of the test app, I've added this little script that allows you to quickly start listening for webhooks and forward them to the Laravel app. With this in place, all you need to do is:

composer listen-stripe

screenshot

While the command is running, a subscription can be made in Spark's billing portal. Here's how it looks like when a subscription has been made.

screenshot.

By comparing the tables and stored values of the live Oh Dear app and this spark test app, I could write migrations that modify the table structure towards what Spark/JetStream expect. I've also written migrations to copy the old data before any tables or columns are dropped.

You'll find the migrations that I'll use in this public gist on GitHub. I have tested this migration by copying over production data to my local Mac to verify if everything worked as expected. I highly encourage you to do the same.

Customizing the looks of the billing portal

Here's how Jetstreams team management screen looks like in Oh Dear.

screenshot.

To make it look at why I published Jetstreams views and let them use our application layout, our top bar is displayed.

Now, if we take a look again at the Sparks default billing portal page, it's clear that it does not have the same look and feel.

screenshot.

Luckily, this can easily be solved. Sparks views are publishable. When published, you can modify the views.

First, I wanted to get rid of that prominent sidebar. That section has an id, so some extra CSS can hide it.

<!-- somewhere on the page -->

<style> #sideBar { display: none !important; } </style>

That's the easiest way. You could also opt to include your compiled CSS on the page and write that #sidebar in your existing CSS files. I have gone that route, and I've also included the HTML of the top navigation bar that is displayed on every other page of Oh Dear.

<head>
<!-- all other head stuff omitted for brevity -->
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
</head>
<body class="font-sans antialiased">
@include('app.layouts.partials.navbar')
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Billing for {{ currentTeam()->name }}
</h2>
</div>
</header>

<!-- all other body stuff omitted for brevity --> </body>

With this in place, the billing portal now looks as if its part of the rest of the app.

screenshot

Customizing the Jetstream screens

In Oh Dear, we've recently added the ability to snooze notifications until the start of the next workday. To determine the start of the next workday, the business hours scheduled can be specified. Here's how that looks like in the Oh Dear with classic Spark.

screenshot.

Adding a custom panel to a Jetstream screen is not difficult. Before I explain it, let's look at the result first (I had to scroll down a bit so you don't see the site header in the screenshot).

screenshot

Each panel is its own Livewire component. You'll find the Blade view that renders the entire page in /resources/views/team/show.blade.php. In that view, you can make any modifications you want. Here's how the modified content of that view looks like.

<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Team Settings for {{ currentTeam()->name }}
</h2>
</x-slot>
<span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"max-w-7xl mx-auto py-10 sm:px-6 lg:px-8"</span>&gt;</span>
        @livewire('teams.update-team-name-form', ['team' =&gt; $team])

        @livewire('teams.team-member-manager', ['team' =&gt; $team])

        @if (Gate::check('update', $team))
            <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-section-border</span>/&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">livewire:business-hours</span> <span class="hljs-attr">:team</span>=<span class="hljs-string">"$team"</span>/&gt;</span>
        @endif

        @if (Gate::check('delete', $team) &amp;&amp; ! $team-&gt;personal_team)
            <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-section-border</span>/&gt;</span>

            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-10 sm:mt-0"</span>&gt;</span>
                @livewire('teams.delete-team-form', ['team' =&gt; $team])
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        @endif
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

</x-app-layout>

You'll notice that I've added a business-hours component to the view. It's put in a Gate check so only people that are allowed to update the team will see the business hours panel.

I'm not going to copy-paste the contents of the component or its view here. Just know that you can add any component or modification you want in the Jetstream's views.

Reusing Jetstream's components in your views

When you want to delete a team, you'll have press the "Delete Team" button display on the team settings view.

screenshot

When clicking the button, Jetstream shows this beautiful modal by default.

screenshot

If we take a look at the resources/views/teams/delete-team-form.blade.php, we see the HTML of both the delete button and that modal's content.

<x-jet-action-section>
<x-slot name="title">
{{ __('Delete Team') }}
</x-slot>
<span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"description"</span>&gt;</span>
    {{ __('Permanently delete this team.') }}
<span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

<span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"max-w-xl text-gray-600"</span>&gt;</span>
        {{ __('Once a team is deleted, all of its resources and data will be permanently deleted. Before deleting this team, please download any data or information regarding this team that you wish to retain.') }}
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-5"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-danger-button</span> <span class="hljs-attr">wire:click</span>=<span class="hljs-string">"$toggle('confirmingTeamDeletion')"</span> <span class="hljs-attr">wire:loading.attr</span>=<span class="hljs-string">"disabled"</span>&gt;</span>
            {{ __('Delete Team') }}
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-danger-button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-comment">&lt;!-- Delete Team Confirmation Modal --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-confirmation-modal</span> <span class="hljs-attr">wire:model</span>=<span class="hljs-string">"confirmingTeamDeletion"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>&gt;</span>
            {{ __('Delete Team') }}
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

        <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>&gt;</span>
            {{ __('Are you sure you want to delete this team? Once a team is deleted, all of its resources and data will be permanently deleted.') }}
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

        <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"footer"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-secondary-button</span> <span class="hljs-attr">wire:click</span>=<span class="hljs-string">"$toggle('confirmingTeamDeletion')"</span> <span class="hljs-attr">wire:loading.attr</span>=<span class="hljs-string">"disabled"</span>&gt;</span>
                {{ __('Nevermind') }}
            <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-secondary-button</span>&gt;</span>

            <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-danger-button</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"ml-2"</span> <span class="hljs-attr">wire:click</span>=<span class="hljs-string">"deleteTeam"</span> <span class="hljs-attr">wire:loading.attr</span>=<span class="hljs-string">"disabled"</span>&gt;</span>
                {{ __('Delete Team') }}
            <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-danger-button</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-confirmation-modal</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

</x-jet-action-section>

This view uses the x-jet-confirmation-modal component. Strings are passed to the various slots. In the Livewire component class that backs this view, the open state is tracked with the $confirmingTeamDeletion. When "Delete" is clicked, the deleteTeam function on the Livewire component class will be executed.

We can easily reuse the view code of the code snippet above to create deletion modals that can be used outside of Jetstream's user and team views.

Here's how the button to delete a site from Oh Dear looks like.

screenshot

When clicking the button, this model is shown.

screenshot

That button and model uses code copied from Jetstream. Here's the code that renders the button and modal.

<div>
<div class="mt-5">
<x-jet-danger-button wire:click="$toggle('confirming')" wire:loading.attr="disabled">
Delete Site
</x-jet-danger-button>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"help mt-4"</span>&gt;</span>
        This will completely remove {{ $site-&gt;label }} from your account. This action cannot be reversed.
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

<span class="hljs-tag">&lt;<span class="hljs-name">x-jet-confirmation-modal</span> <span class="hljs-attr">wire:model</span>=<span class="hljs-string">"confirming"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>&gt;</span>
        Delete Site
    <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>&gt;</span>
        Are you sure you want to remove <span class="hljs-tag">&lt;<span class="hljs-name">b</span>&gt;</span>{{ $site-&gt;label }}<span class="hljs-tag">&lt;/<span class="hljs-name">b</span>&gt;</span> from Oh Dear?
        All history will be deleted as well
    <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"footer"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-secondary-button</span> <span class="hljs-attr">wire:click</span>=<span class="hljs-string">"$toggle('confirming')"</span> <span class="hljs-attr">wire:loading.attr</span>=<span class="hljs-string">"disabled"</span>&gt;</span>
            Nevermind
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-secondary-button</span>&gt;</span>

        <span class="hljs-tag">&lt;<span class="hljs-name">x-jet-danger-button</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"ml-2"</span> <span class="hljs-attr">wire:click</span>=<span class="hljs-string">"confirmed"</span> <span class="hljs-attr">wire:loading.attr</span>=<span class="hljs-string">"disabled"</span>&gt;</span>
            Delete site
        <span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-danger-button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">x-slot</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">x-jet-confirmation-modal</span>&gt;</span>

</div>

When clicking the delete button, the "confirmed" function is executed. Here is the Livewire component that controls that view.

namespace App<span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;

use App<span class="hljs-title">Domain<span class="hljs-title">Site<span class="hljs-title">Models<span class="hljs-title">Site;

class DeleteSiteComponent extends ConfirmComponent { public Site $site;

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">confirmed</span><span class="hljs-params">()</span>
</span>{
	    <span class="hljs-comment">// delete everything using a queued job</span>
    <span class="hljs-keyword">$this</span>-&gt;site-&gt;markForDeletion();

    flash()-&gt;success(<span class="hljs-string">"{$this-&gt;site-&gt;label} has been removed from your account"</span>);

    <span class="hljs-keyword">$this</span>-&gt;redirectRoute(<span class="hljs-string">'sites'</span>);
}

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render</span><span class="hljs-params">()</span>
</span>{
    <span class="hljs-keyword">return</span> view(<span class="hljs-string">'app.sites.components.confirm.delete-site'</span>);
}

}

Here is the code of the parent class ConfirmComponent.

namespace App<span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;

use Livewire<span class="hljs-title">Component;

abstract class ConfirmComponent extends Component { public $confirming = false;

<span class="hljs-keyword">abstract</span> <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">confirmed</span><span class="hljs-params">()</span></span>;

}

You see that variable that determines the open/close state of the model is set on the parent, so DeleteSiteComponent doesn't need to bother with it itself.

Here is the Livewire component that handles the deletion of another model. You see that it is also very small and also extends ConfirmComponent.

namespace App<span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;

use App<span class="hljs-title">Domain<span class="hljs-title">StatusPage<span class="hljs-title">Models<span class="hljs-title">StatusPage;

class DeleteStatusPageComponent extends ConfirmComponent { public StatusPage $statusPage;

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">confirmed</span><span class="hljs-params">()</span>
</span>{
    <span class="hljs-keyword">$this</span>-&gt;statusPage-&gt;delete();

    flash()-&gt;success(<span class="hljs-string">"{$this-&gt;statusPage-&gt;title} has been deleted"</span>);

    <span class="hljs-keyword">$this</span>-&gt;redirectRoute(<span class="hljs-string">'statusPages'</span>);
}

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render</span><span class="hljs-params">()</span>
</span>{
    <span class="hljs-keyword">return</span> view(<span class="hljs-string">'app.sites.components.confirm.delete-status-page'</span>);
}

}

And that's how you can reuse Jetstream's confirmation modals.

Bringing back impersonating users

In classic spark, you could impersonate users. Because Spark now focuses on only being a billing portal and nothing else, that the impersonation functionality was removed.

Luckily, you can bring it back quickly. For Oh Dear, we're using Laravel Nova. The kabbouchi/nova-impersonate package can display an impersonate button next to each user in a Nova module.

screenshot

When you click it, you can see use Oh Dear as if you are that user. That's pretty handy when working on support tickets: you can see what the user sees.

screenshot

Avoid using Jetstream's personal teams

By default, Jetstream has a concept that is called "personal teams". Whenever a user registers, a team will be created for that user. This is called their personal team. A user can not leave or delete that team. This ensures that each user will always have at least one team.

Though there certainly will be use cases for this behaviour, it could also create many unneeded teams. A registered user can invite other people into a team using their email address.

Here, I've invited "[email protected]" to the Spatie team.

screenshot

This is the mail that will be sent to John.

screenshot

So, John is instructed to create an account. After John creates the account, a personal team (that cannot be deleted by default) will automatically have been created. After that, John clicks the "Accept Invitation" button in the mail and joins the Spatie team. Now John belongs to two teams: his personal team (that he probably doesn't need) and the Spatie team.

I'm confident that, for Oh Dear, using Jetstream's default behaviour would result in many unneeded teams being created. I'm not worried about the amount of storage that takes (it's probably negligible compared to all other stuff in the DB), but this behaviour might confuse users who expect to be in one team.

Luckily, customizing this behaviour is straightforward. Jetstream puts so-called action classes in your project. The class that will get executed when a new user registers is AppActionsFortifyCreateNewUser.

This is the default implementation.

namespace App<span class="hljs-title">Actions<span class="hljs-title">Fortify;

use App<span class="hljs-title">Models<span class="hljs-title">Team; use App<span class="hljs-title">Models<span class="hljs-title">User; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">DB; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Hash; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Validator; use Laravel<span class="hljs-title">Fortify<span class="hljs-title">Contracts<span class="hljs-title">CreatesNewUsers; use Laravel<span class="hljs-title">Jetstream<span class="hljs-title">Jetstream;

class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules;

<span class="hljs-comment">/**
 * Create a newly registered user.
 *
 * <span class="hljs-doctag">@param</span>  array  $input
 * <span class="hljs-doctag">@return</span> AppModelsUser
 */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">create</span><span class="hljs-params">(array $input)</span>
</span>{
    Validator::make($input, [
        <span class="hljs-string">'name'</span> =&gt; [<span class="hljs-string">'required'</span>, <span class="hljs-string">'string'</span>, <span class="hljs-string">'max:255'</span>],
        <span class="hljs-string">'email'</span> =&gt; [<span class="hljs-string">'required'</span>, <span class="hljs-string">'string'</span>, <span class="hljs-string">'email'</span>, <span class="hljs-string">'max:255'</span>, <span class="hljs-string">'unique:users'</span>],
        <span class="hljs-string">'password'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;passwordRules(),
        <span class="hljs-string">'terms'</span> =&gt; Jetstream::hasTermsAndPrivacyPolicyFeature() ? [<span class="hljs-string">'required'</span>, <span class="hljs-string">'accepted'</span>] : <span class="hljs-string">''</span>,
    ])-&gt;validate();

    <span class="hljs-keyword">return</span> DB::transaction(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">()</span> <span class="hljs-title">use</span> <span class="hljs-params">($input)</span> </span>{
        <span class="hljs-keyword">return</span> tap(User::create([
            <span class="hljs-string">'name'</span> =&gt; $input[<span class="hljs-string">'name'</span>],
            <span class="hljs-string">'email'</span> =&gt; $input[<span class="hljs-string">'email'</span>],
            <span class="hljs-string">'password'</span> =&gt; Hash::make($input[<span class="hljs-string">'password'</span>]),
        ]), <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(User $user)</span> </span>{
            <span class="hljs-keyword">$this</span>-&gt;createTeam($user);
        });
    });
}

<span class="hljs-comment">/**
 * Create a personal team for the user.
 *
 * <span class="hljs-doctag">@param</span>  AppModelsUser  $user
 * <span class="hljs-doctag">@return</span> void
 */</span>
<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createTeam</span><span class="hljs-params">(User $user)</span>
</span>{
    $user-&gt;ownedTeams()-&gt;save(Team::forceCreate([
        <span class="hljs-string">'user_id'</span> =&gt; $user-&gt;id,
        <span class="hljs-string">'name'</span> =&gt; explode(<span class="hljs-string">' '</span>, $user-&gt;name, <span class="hljs-number">2</span>)[<span class="hljs-number">0</span>].<span class="hljs-string">"'s Team"</span>,
        <span class="hljs-string">'personal_team'</span> =&gt; <span class="hljs-keyword">true</span>,
    ]));
}

}

You clearly see the personal team being created too. In the Oh Dear codebase, we've customized that class like this:

namespace App<span class="hljs-title">Actions<span class="hljs-title">Fortify;

use App<span class="hljs-title">Domain<span class="hljs-title">Team<span class="hljs-title">Models<span class="hljs-title">User; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Hash; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Validator; use Laravel<span class="hljs-title">Fortify<span class="hljs-title">Contracts<span class="hljs-title">CreatesNewUsers; use Laravel<span class="hljs-title">Jetstream<span class="hljs-title">Jetstream;

class CreateNewUser implements CreatesNewUsers { use PasswordValidationRules;

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">create</span><span class="hljs-params">(array $input)</span>
</span>{
    Validator::make($input, [
        <span class="hljs-string">'name'</span> =&gt; [<span class="hljs-string">'required'</span>, <span class="hljs-string">'string'</span>, <span class="hljs-string">'max:255'</span>],
        <span class="hljs-string">'email'</span> =&gt; [<span class="hljs-string">'required'</span>, <span class="hljs-string">'string'</span>, <span class="hljs-string">'email'</span>, <span class="hljs-string">'max:255'</span>, <span class="hljs-string">'unique:users'</span>],
        <span class="hljs-string">'password'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;passwordRules(),
        <span class="hljs-string">'terms'</span> =&gt; Jetstream::hasTermsAndPrivacyPolicyFeature() ? [<span class="hljs-string">'required'</span>, <span class="hljs-string">'accepted'</span>] : <span class="hljs-string">''</span>,
    ])-&gt;validate();

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> User $user */</span>
    $user = User::create([
        <span class="hljs-string">'name'</span> =&gt; $input[<span class="hljs-string">'name'</span>],
        <span class="hljs-string">'email'</span> =&gt; $input[<span class="hljs-string">'email'</span>],
        <span class="hljs-string">'password'</span> =&gt; Hash::make($input[<span class="hljs-string">'password'</span>]),
    ]);

    <span class="hljs-keyword">return</span> $user;
}

}

After this action is executed, the calling code in Jetstream will redirect the user to the home route. In Oh Dear, the home route is defined in the RouteServiceProvider to point to /sites.

class RouteServiceProvider extends ServiceProvider
{
public const HOME = '/sites';
<span class="hljs-comment">// ...</span>

}

Most application routes in our app have the EnsureHasTeam middleware applied to them.

This is the code of that middleware.

<?php

namespace App<span class="hljs-title">Http

<span class="hljs-title">Middleware;

use Closure; use Illuminate<span class="hljs-title">Http<span class="hljs-title">Request;

class EnsureHasTeam { public function handle(Request $request, Closure $next) { if (! currentUser()->isMemberOfATeam()) { return redirect()->route('create-first-team'); }

    <span class="hljs-keyword">$this</span>-&gt;ensureOneOfTheTeamsIsCurrent();

    <span class="hljs-keyword">return</span> $next($request);
}

<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ensureOneOfTheTeamsIsCurrent</span><span class="hljs-params">()</span>: <span class="hljs-title">void</span>
</span>{
    <span class="hljs-keyword">if</span> (! is_null(currentUser()-&gt;current_team_id)) {
        <span class="hljs-keyword">return</span>;
    }

    $firstTeamId = currentUser()-&gt;allTeams()-&gt;first()-&gt;id;
    currentUser()-&gt;update([<span class="hljs-string">'current_team_id'</span> =&gt; $firstTeamId]);

}

}

This middleware will redirect any user that doesn't have a team to the create-first-team route. This route displays a form that allows the logged-in user to create a team. This is how that form looks like in the browser.

screenshot.

At this point, no team has been created yet. Notice that "Invited to join an existing team?" link at the bottom of the form? Let's click it!

screenshot.

Here we instruct the user to click auth "Accept Invitation" button in the mail. When that button is clicked, the user will be joining that team.

On the route that displays the "Create first team" form, there's a middleware applied called EnsureHasNoTeam. Here is the code.


namespace App<span class="hljs-title">Http<span class="hljs-title">Middleware;

use App<span class="hljs-title">Providers<span class="hljs-title">RouteServiceProvider; use Closure; use Illuminate<span class="hljs-title">Http<span class="hljs-title">Request;

class EnsureHasNoTeam { public function handle(Request $request, Closure $next) { if (currentUser()->isMemberOfATeam()) { return redirect()->route(RouteServiceProvider::HOME); }

    <span class="hljs-keyword">return</span> $next($request);
}

}

This middleware ensures that only users without any team can see the "Create first team" form.

And with all of this in plans, we don't rely on "personal teams". People that get invited into a team won't be forced to belong to multiple teams.

Creating a fixed invoices table

When Spark receives a webhook from Stripe signalling that a payment has been made, Spark will create store a new row in the receipts table and mail a PDF version of the receipt.

In the receipts table, these fields are stored.

Schema::create('receipts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->index();
$table->string('provider_id')->index();
$table->string('amount');
$table->string('tax');
$table->timestamp('paid_at');
$table->timestamps();
});

That's all wonderful, but for Belgian (and I guess most European countries, this is not enough. The problem is that the table only holds a reference to the team id. If someone in the team changes the team's billing address, this will also change any previous receipts. For Belgian law, you must always use the address when a receipt/invoice is made.

Luckily, this is easily solved. When Spark handles a payment, it will fire an event SparkEventsPaymentSucceeded that holds a reference to the billable (in our case, a team) and the created invoice. We can use that event to build up a table invoices that holds the team's data when a payment was made.

Here is the code of the listener that writes to the invoices table.

namespace App<span class="hljs-title">Domain<span class="hljs-title">Subscription<span class="hljs-title">Listeners;

use App<span class="hljs-title">Domain<span class="hljs-title">Subscription<span class="hljs-title">Models<span class="hljs-title">Invoice; use Laravel<span class="hljs-title">Cashier<span class="hljs-title">Invoice as CashierInvoice; use Spark<span class="hljs-title">Events<span class="hljs-title">PaymentSucceeded;

class CreateInvoice { public function handle(PaymentSucceeded $event) { /** @var AppDomainTeamModelsTeam $team */ $team = $event->billable;

    <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> CashierInvoice $invoice */</span>
    $cashierInvoice = $event-&gt;invoice;

    $stripeInvoice = $cashierInvoice-&gt;asStripeInvoice();

    <span class="hljs-keyword">if</span> (Invoice::findByStripeId($stripeInvoice-&gt;id)) {
        <span class="hljs-keyword">return</span>;
    }

    Invoice::create([
        <span class="hljs-string">'team_id'</span> =&gt; $team-&gt;id,
        <span class="hljs-string">'stripe_id'</span> =&gt; $cashierInvoice-&gt;id,
        <span class="hljs-string">'total'</span> =&gt; $cashierInvoice-&gt;rawTotal(),
        <span class="hljs-string">'tax'</span> =&gt; $stripeInvoice-&gt;tax,
        <span class="hljs-string">'card_country'</span> =&gt; $team-&gt;card_country,
        <span class="hljs-string">'billing_address'</span> =&gt; $team-&gt;billing_address,
        <span class="hljs-string">'billing_address_line_2'</span> =&gt; $team-&gt;billing_address_line_2,
        <span class="hljs-string">'billing_postal_code'</span> =&gt; $team-&gt;billing_postal_code,
        <span class="hljs-string">'billing_state'</span> =&gt; $team-&gt;billing_state,
        <span class="hljs-string">'billing_country'</span> =&gt; $team-&gt;billing_country,
        <span class="hljs-string">'vat_id'</span> =&gt; $team-&gt;vat_id,
    ]);
}

}

And with that date both the tax authorities and our accountant is happy.

In closing

Even though I needed to customize some behaviour of the new Spark, I'm thrilled with it. All these customizations were easily achievable; no dirty hacks were needed. The Laravel team did a good job making both Jetstream and Spark customizable out of the box.

In general, I think the new Spark is a significant step forwards compared to the classic version. The Oh Dear Laravel app doesn't have any special foundation (classic Spark) now, but it's more a regular Laravel app with some add-ons (Jetstream and Spark). This feels much lighter. We could remove Vue from the code base and can now solely rely on Livewire, making our app more maintainable.

I'd like to thank Mohamed Said for answering questions around some of the implementation details and adding small niceties to Spark when I needed them..

If you want to see all of this in action, register for an account at Oh Dear. There's a free trial period of 10 days so that you can toy around with all the new user, team and billing settings screen.

(more…)

By , ago