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.
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.
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:
- Create a separate branch of the oh dear repo, so I could still refer to the
master
branch to see how things worked - Delete all publish spark Vue components
- Remove the
spark-aurelius
dependency incomposer.json
and remove any service providers related to it - Install JetStream and
laravel/spark-stripe
- Migrate the database to the new format
- 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
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.
.
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.
.
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.
.
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.
<span class="hljs-comment"><!-- somewhere on the page --></span>
<p><span class="hljs-tag"><<span class="hljs-name">style</span>></span><span class="css">
<span class="hljs-selector-id">#sideBar</span> {
<span class="hljs-attribute">display</span>: none <span class="hljs-meta">!important</span>;
}
</span><span class="hljs-tag"></<span class="hljs-name">style</span>></span>
</p>
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.
<span class="hljs-tag"><<span class="hljs-name">head</span>></span>
<span class="hljs-comment"><!-- all other head stuff omitted for brevity --></span>
<span class="hljs-tag"><<span class="hljs-name">link</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"{{ mix('css/app.css') }}"</span>></span>
<span class="hljs-tag"></<span class="hljs-name">head</span>></span>
<span class="hljs-tag"><<span class="hljs-name">body</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"font-sans antialiased"</span>></span>
@include('app.layouts.partials.navbar')
<span class="hljs-tag"><<span class="hljs-name">header</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white shadow"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">h2</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"font-semibold text-xl text-gray-800 leading-tight"</span>></span>
Billing for {{ currentTeam()->name }}
<span class="hljs-tag"></<span class="hljs-name">h2</span>></span>
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"></<span class="hljs-name">header</span>></span>
<p><span class="hljs-comment"><!-- all other body stuff omitted for brevity --></span>
<span class="hljs-tag"></<span class="hljs-name">body</span>></span>
</p>
With this in place, the billing portal now looks as if its part of the rest of the app.
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.
.
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).
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.
<span class="hljs-tag"><<span class="hljs-name">x-app-layout</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"header"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">h2</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"font-semibold text-xl text-gray-800 leading-tight"</span>></span>
Team Settings for {{ currentTeam()->name }}
<span class="hljs-tag"></<span class="hljs-name">h2</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<pre><code><span class="hljs-tag"><<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<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>></span>
@livewire('teams.update-team-name-form', ['team' => $team])
@livewire('teams.team-member-manager', ['team' => $team])
@if (Gate::check('update', $team))
<span class="hljs-tag"><<span class="hljs-name">x-jet-section-border</span>/></span>
<span class="hljs-tag"><<span class="hljs-name">livewire:business-hours</span> <span class="hljs-attr">:team</span>=<span class="hljs-string">"$team"</span>/></span>
@endif
@if (Gate::check('delete', $team) && ! $team->personal_team)
<span class="hljs-tag"><<span class="hljs-name">x-jet-section-border</span>/></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-10 sm:mt-0"</span>></span>
@livewire('teams.delete-team-form', ['team' => $team])
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
@endif
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"></<span class="hljs-name">div</span>></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.
When clicking the button, Jetstream shows this beautiful modal by default.
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.
<span class="hljs-tag"><<span class="hljs-name">x-jet-action-section</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>></span>
{{ __('Delete Team') }}
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<pre><code><span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"description"</span>></span>
{{ __('Permanently delete this team.') }}
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"max-w-xl text-gray-600"</span>></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"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-5"</span>></span>
<span class="hljs-tag"><<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>></span>
{{ __('Delete Team') }}
<span class="hljs-tag"></<span class="hljs-name">x-jet-danger-button</span>></span>
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-comment"><!-- Delete Team Confirmation Modal --></span>
<span class="hljs-tag"><<span class="hljs-name">x-jet-confirmation-modal</span> <span class="hljs-attr">wire:model</span>=<span class="hljs-string">"confirmingTeamDeletion"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>></span>
{{ __('Delete Team') }}
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>></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"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"footer"</span>></span>
<span class="hljs-tag"><<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>></span>
{{ __('Nevermind') }}
<span class="hljs-tag"></<span class="hljs-name">x-jet-secondary-button</span>></span>
<span class="hljs-tag"><<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>></span>
{{ __('Delete Team') }}
<span class="hljs-tag"></<span class="hljs-name">x-jet-danger-button</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-jet-confirmation-modal</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></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.
When clicking the button, this model is shown.
That button and model uses code copied from Jetstream. Here’s the code that renders the button and modal.
<span class="hljs-tag"><<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-5"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-jet-danger-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>></span>
Delete Site
<span class="hljs-tag"></<span class="hljs-name">x-jet-danger-button</span>></span>
<pre><code> <span class="hljs-tag"><<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"help mt-4"</span>></span>
This will completely remove {{ $site->label }} from your account. This action cannot be reversed.
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"></<span class="hljs-name">div</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-jet-confirmation-modal</span> <span class="hljs-attr">wire:model</span>=<span class="hljs-string">"confirming"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>></span>
Delete Site
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>></span>
Are you sure you want to remove <span class="hljs-tag"><<span class="hljs-name">b</span>></span>{{ $site->label }}<span class="hljs-tag"></<span class="hljs-name">b</span>></span> from Oh Dear?
All history will be deleted as well
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"><<span class="hljs-name">x-slot</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"footer"</span>></span>
<span class="hljs-tag"><<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>></span>
Nevermind
<span class="hljs-tag"></<span class="hljs-name">x-jet-secondary-button</span>></span>
<span class="hljs-tag"><<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>></span>
Delete site
<span class="hljs-tag"></<span class="hljs-name">x-jet-danger-button</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-slot</span>></span>
<span class="hljs-tag"></<span class="hljs-name">x-jet-confirmation-modal</span>></span>
</div>
When clicking the delete button, the “confirmed” function is executed. Here is the Livewire component that controls that view.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Site<span class="hljs-title">Models<span class="hljs-title">Site;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DeleteSiteComponent</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ConfirmComponent</span>
</span>{
<span class="hljs-keyword">public</span> Site $site;</p>
<pre><code><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>->site->markForDeletion();
flash()->success(<span class="hljs-string">"{$this->site->label} has been removed from your account"</span>);
<span class="hljs-keyword">$this</span>->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
.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">Livewire</span><span class="hljs-title">Component;</p>
<p><span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ConfirmComponent</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Component</span>
</span>{
<span class="hljs-keyword">public</span> $confirming = <span class="hljs-keyword">false</span>;</p>
<pre><code><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
.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Http<span class="hljs-title">App<span class="hljs-title">Components<span class="hljs-title">ConfirmComponents;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">StatusPage<span class="hljs-title">Models<span class="hljs-title">StatusPage;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DeleteStatusPageComponent</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ConfirmComponent</span>
</span>{
<span class="hljs-keyword">public</span> StatusPage $statusPage;</p>
<pre><code><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>->statusPage->delete();
flash()->success(<span class="hljs-string">"{$this->statusPage->title} has been deleted"</span>);
<span class="hljs-keyword">$this</span>->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.
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.
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.
This is the mail that will be sent to John.
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.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Actions<span class="hljs-title">Fortify;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Models<span class="hljs-title">Team;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Models<span class="hljs-title">User;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">DB;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Hash;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Validator;
<span class="hljs-keyword">use</span> <span class="hljs-title">Laravel</span><span class="hljs-title">Fortify<span class="hljs-title">Contracts<span class="hljs-title">CreatesNewUsers;
<span class="hljs-keyword">use</span> <span class="hljs-title">Laravel</span><span class="hljs-title">Jetstream<span class="hljs-title">Jetstream;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CreateNewUser</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">CreatesNewUsers</span>
</span>{
<span class="hljs-keyword">use</span> <span class="hljs-title">PasswordValidationRules</span>;</p>
<pre><code><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> => [<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> => [<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> => <span class="hljs-keyword">$this</span>->passwordRules(),
<span class="hljs-string">'terms'</span> => Jetstream::hasTermsAndPrivacyPolicyFeature() ? [<span class="hljs-string">'required'</span>, <span class="hljs-string">'accepted'</span>] : <span class="hljs-string">''</span>,
])->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> => $input[<span class="hljs-string">'name'</span>],
<span class="hljs-string">'email'</span> => $input[<span class="hljs-string">'email'</span>],
<span class="hljs-string">'password'</span> => 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>->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->ownedTeams()->save(Team::forceCreate([
<span class="hljs-string">'user_id'</span> => $user->id,
<span class="hljs-string">'name'</span> => explode(<span class="hljs-string">' '</span>, $user->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> => <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:
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Actions<span class="hljs-title">Fortify;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Team<span class="hljs-title">Models<span class="hljs-title">User;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Hash;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Validator;
<span class="hljs-keyword">use</span> <span class="hljs-title">Laravel</span><span class="hljs-title">Fortify<span class="hljs-title">Contracts<span class="hljs-title">CreatesNewUsers;
<span class="hljs-keyword">use</span> <span class="hljs-title">Laravel</span><span class="hljs-title">Jetstream<span class="hljs-title">Jetstream;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CreateNewUser</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">CreatesNewUsers</span>
</span>{
<span class="hljs-keyword">use</span> <span class="hljs-title">PasswordValidationRules</span>;</p>
<pre><code><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> => [<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> => [<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> => <span class="hljs-keyword">$this</span>->passwordRules(),
<span class="hljs-string">'terms'</span> => Jetstream::hasTermsAndPrivacyPolicyFeature() ? [<span class="hljs-string">'required'</span>, <span class="hljs-string">'accepted'</span>] : <span class="hljs-string">''</span>,
])->validate();
<span class="hljs-comment">/** <span class="hljs-doctag">@var</span> User $user */</span>
$user = User::create([
<span class="hljs-string">'name'</span> => $input[<span class="hljs-string">'name'</span>],
<span class="hljs-string">'email'</span> => $input[<span class="hljs-string">'email'</span>],
<span class="hljs-string">'password'</span> => 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
.
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">RouteServiceProvider</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ServiceProvider</span>
</span>{
<span class="hljs-keyword">public</span> <span class="hljs-keyword">const</span> HOME = <span class="hljs-string">'/sites'</span>;
<pre><code><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.
<span class="php"><span class="hljs-meta"><?php</span>
<p><span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Http</p></span><span class="hljs-title">Middleware;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">Closure</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Http<span class="hljs-title">Request;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">EnsureHasTeam</span>
</span>{
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span><span class="hljs-params">(Request $request, Closure $next)</span>
</span>{
<span class="hljs-keyword">if</span> (! currentUser()->isMemberOfATeam()) {
<span class="hljs-keyword">return</span> redirect()->route(<span class="hljs-string">'create-first-team'</span>);
}</p>
<pre><code> <span class="hljs-keyword">$this</span>->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()->current_team_id)) {
<span class="hljs-keyword">return</span>;
}
$firstTeamId = currentUser()->allTeams()->first()->id;
currentUser()->update([<span class="hljs-string">'current_team_id'</span> => $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.
.
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!
.
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.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Http<span class="hljs-title">Middleware;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Providers<span class="hljs-title">RouteServiceProvider;
<span class="hljs-keyword">use</span> <span class="hljs-title">Closure</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span><span class="hljs-title">Http<span class="hljs-title">Request;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">EnsureHasNoTeam</span>
</span>{
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span><span class="hljs-params">(Request $request, Closure $next)</span>
</span>{
<span class="hljs-keyword">if</span> (currentUser()->isMemberOfATeam()) {
<span class="hljs-keyword">return</span> redirect()->route(RouteServiceProvider::HOME);
}</p>
<pre><code> <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(<span class="hljs-string">'receipts'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(Blueprint $table)</span> </span>{
$table->id();
$table->foreignId(<span class="hljs-string">'team_id'</span>)->index();
$table->string(<span class="hljs-string">'provider_id'</span>)->index();
$table->string(<span class="hljs-string">'amount'</span>);
$table->string(<span class="hljs-string">'tax'</span>);
$table->timestamp(<span class="hljs-string">'paid_at'</span>);
$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.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Subscription<span class="hljs-title">Listeners;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Subscription<span class="hljs-title">Models<span class="hljs-title">Invoice;
<span class="hljs-keyword">use</span> <span class="hljs-title">Laravel</span><span class="hljs-title">Cashier<span class="hljs-title">Invoice <span class="hljs-title">as</span> <span class="hljs-title">CashierInvoice</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Spark</span><span class="hljs-title">Events<span class="hljs-title">PaymentSucceeded;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CreateInvoice</span>
</span>{
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span><span class="hljs-params">(PaymentSucceeded $event)</span>
</span>{
<span class="hljs-comment">/** <span class="hljs-doctag">@var</span> AppDomainTeamModelsTeam $team */</span>
$team = $event->billable;</p>
<pre><code> <span class="hljs-comment">/** <span class="hljs-doctag">@var</span> CashierInvoice $invoice */</span>
$cashierInvoice = $event->invoice;
$stripeInvoice = $cashierInvoice->asStripeInvoice();
<span class="hljs-keyword">if</span> (Invoice::findByStripeId($stripeInvoice->id)) {
<span class="hljs-keyword">return</span>;
}
Invoice::create([
<span class="hljs-string">'team_id'</span> => $team->id,
<span class="hljs-string">'stripe_id'</span> => $cashierInvoice->id,
<span class="hljs-string">'total'</span> => $cashierInvoice->rawTotal(),
<span class="hljs-string">'tax'</span> => $stripeInvoice->tax,
<span class="hljs-string">'card_country'</span> => $team->card_country,
<span class="hljs-string">'billing_address'</span> => $team->billing_address,
<span class="hljs-string">'billing_address_line_2'</span> => $team->billing_address_line_2,
<span class="hljs-string">'billing_postal_code'</span> => $team->billing_postal_code,
<span class="hljs-string">'billing_state'</span> => $team->billing_state,
<span class="hljs-string">'billing_country'</span> => $team->billing_country,
<span class="hljs-string">'vat_id'</span> => $team->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.