PHP
Creating a Scientific Pitch Notation Type using template literal types
Gotta love TypeScript Read More
Gotta love TypeScript Read More
In this post 8 image loading optimization techniques are outlined to minimize both the bandwidth used for loading images on the web and the CPU usage for image display. Read More
[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.
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.
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:
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.
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:
master
branch to see how things workedspark-aurelius
dependency in composer.json
and remove any service providers related to itlaravel/spark-stripe
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.
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.
<!-- 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.
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.
<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"><<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.
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.
<x-jet-action-section>
<x-slot name="title">
{{ __('Delete Team') }}
</x-slot>
<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.
<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"><<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.
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>->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
.
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>->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.
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.
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.
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> => [<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:
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> => [<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
.
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>->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.
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.
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->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.
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…)In this incredibly well produced course, Tailwind Labs member Simon Vrachliotis teaches you how to get started with Tailwind. Read More
Quick @laravelphp tip: The `MailMessage` class has a `when` method that’s useful to conditionally add things to the mailable. Great for adding optional lines. ð¤ pic.twitter.com/TGGb5J6zk2 — Wilbur Powery E. (@wilburpowery) February 22, 2021
In this post, Dan Abramov shares two different techniques to optimize components without having to reach for memo(). Read More
[AdSense-A]
We've released a new package called spatie/laravel-prefixed-ids. In this post, I'd like to tell you all about it.
When thinking of companies with excellent APIs, Stripe is one of the first that comes to mind. They have excellent docs, and it seems like they have polished every detail to make sure the developer experience is just right.
One of the details they took care of is prefixing all of their ids. Here's an example: when creating a token to interact with their API, you'll notice that that token is prefixed. Let's take a look at such a token:
sk_test_FKdleMEKfiemsiDSMEODL
In the example above, the sk
stands for "secret key", and test
signifies that this token is meant to be used in Stripe's test environment. Without that prefix, the value would just be a random string of which you'd have to guess what it is.
In fact, Stripe prefixes almost all of their ids. In my mind, this is a great little detail.
The spatie/laravel-prefixed-ids can automatically generate prefixed ids in a Laravel app. It offers handy methods to retrieve Eloquent models using those prefixed ids.
You can install the package via Composer. No surprises there.
composer require spatie/laravel-prefixed-ids
On each model that needs a prefixed id, you should use the SpatiePrefixedIdsModelsConcernsHasPrefixedId
trait.
namespace AppModels;
use Illuminate<span class="hljs-title">Database<span class="hljs-title">Eloquent<span class="hljs-title">Model;
use Spatie<span class="hljs-title">PrefixedIds<span class="hljs-title">Models<span class="hljs-title">Concerns<span class="hljs-title">HasPrefixedId;
class YourModel extends Model
{
use HasPrefixedId;
}
You'll need to write a migration to add a prefixed_id
column to its underlying table.
If you wish to use another attribute name, you should publish the config file (see below) and set the prefixed_id_attribute_name
config value to the attribute name of your liking.
Schema::create('your_models_table', function (Blueprint $table) {
$table->string('prefixed_id')->index();
});
To register your models, you should pass the desired prefix and your model's class name to PrefixedIds::registerModels
.
SpatiePrefixedIdsPrefixedIds::registerModels([
'your_prefix_' => YourModel::class,
'another_prefix' => AnotherModel::class,
]);
Typically, you would put the code above in a service provider.
With this in place, when a model is created, it will automatically have a unique, prefixed id in the prefixed_id
attribute.
$model = YourModel::create();
$model->prefixed_id // returns a random id like your_model_fekjlmsme39dmMS
You can find the model with a given prefix by calling findByPrefixedId
on it.
YourModel::findByPrefixedId('your_model_fekjlmsme39dmMS'); // returns an instance of YourModel
YourModel::findByPrefixedId('non-existing-id'); // returns null
You can call find
on SpatiePrefixedIdsPrefixedIds
to automatically get the right model for any given prefixed id.
$yourModel = SpatiePrefixedIdsPrefixedIds::find('your_model_fekjlmsme39dmMS'); // returns an instance of YourModel
or null
$otherModel = SpatiePrefixedIdsPrefixedIds::find('other_model_3Fjmmfsmls'); // returns an instance of OtherModel
or null
To use the prefixed ids in your routes, you'll have to add the getRouteKeyName
method to your model. It should return the name of the attribute that holds the prefixed id.
public function getRouteKeyName()
{
return 'prefixed_id';
}
With this in place a route defined as...
Route::get('your-model/{yourModel}', YourModelController::class)`
... can be invoked with an URL like "/your-model/your_model_fekjlmsme39dmMS".
You'll find more info on route model binding in the Laravel docs.
When creating an API, consider using prefixed ids. The consumers of your API will probably much appreciate this.
spatie/laravel-prefixed-ids isn't the first package our team has created. Be sure to take a look at this list of PHP and Laravel packages our team has created previously.
(more…)Let’s take an in-depth look in this awesome feature that will be available by the end of this year. Read More
Tony Messias been looking up resources on the roots of Object-Oriented Programming. He shares some very interesting ideas. Read More
This page explains use cases and examples of SSH tunnels while visually presenting the traffic flows. Read More