How to send a "trial expiring soon" mail in Laravel Spark
I'm currently building a webapp named Oh Dear: an easy to use and beautiful website monitor. It has recently gone into it's beta phase. At the moment of writing everybody can register a new account. After you've registered you'll start your trial period of 10 days.
Oh Dear is built on top Laravel Spark, a Laravel based template to kick off SaaS projects. It offers logic for organising users into teams, handles trial periods, subscriptions, payments, invoices and much more.
Spark includes the option of lettings users create and join teams. When this team mode is active a subscription is related to a team and not a user. When a user registers and creates his/her first team, there is no subscription active, but the team is in a trial period.
Unfortunately Spark will not sent out a mail to team owners whose teams are in trial periods that will soon expire. Luckily it's easy to add that yourself. I'll show you how to do just that in this post. Along the way you'll learn some good general tips for sending out mails in batches. Let's get started!
Determining which teams should be mailed
First we'll add a function to the Team
model that determines if the team is on a trial that will expire soon. This function will return true
for teams without a subscription and whose trials will end less that two days of the current time.
public function onSoonExpiringTrial(): bool
{
if ($this->subscribed()) {
return false;
}
if (! $this->onGenericTrial()) {
return false;
}
return now()->addDays(2)->greaterThan($this->trial_ends_at);
}
Adding an extra field to the teams table
Next we should create a migration to add a field called trial_expiring_mail_sent_at
to the teams table. We'll explain why we need that field in the next section.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTrialExpiringMailSentAtToTeamsTable extends Migration
{
public function up()
{
Schema::table('teams', function (Blueprint $table) {
$table->timestamp('trial_expiring_mail_sent_at')->nullable();
});
}
}
Mailing the owner of the teams
The actual mails are sent in an Artisan command named ohdear:email-teams-with-expiring-trials
. That command is scheduled to run daily. Here is the actual code of that command:
namespace App\Console\Commands;
use App\Mail\TrialExpiringSoon;
use App\Team;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class EmailTeamsWithExpiringTrials extends Command
{
protected $signature = 'ohdear:email-teams-with-expiring-trials';
protected $description = 'Email teams with expiring trials.';
protected $mailsSent = 0;
protected $mailFailures = 0;
public function handle()
{
$this->info('Sending trial expiring soon mails...');
Team::all()
->filter->onSoonExpiringTrial()
->each(function (Team $team) {
$this->sendTrialEndingSoonMail($team);
});
$this->info("{$this->mailsSent} trial expiring mails sent!");
if ($this->mailFailures > 0) {
$this->error("Failed to send {$this->mailFailures} trial expiring mails!");
}
}
protected function sendTrialEndingSoonMail(Team $team)
{
try {
if ($team->wasAlreadySentTrialExpiringSoonMail()) {
return;
}
$this->comment("Mailing {$team->owner->email} (team {$team->name})");
Mail::to($team->owner->email)->send(new TrialExpiringSoon($team));
$this->mailsSent++;
$team->rememberHasBeenSentTrialExpiringSoonMail();
} catch (Exception $exception) {
$this->error("exception when sending mail to team {$team->id}", $exception);
report($exception);
$this->mailFailures++;
}
}
}
The actual sending of the mail in sendTrialEndingSoonMail
is wrapped in a try/catch block. So if something goes wrong sending the mail the command doesn't blow up, but continues with the next team.
Before sending a mail we'll call wasAlreadySentTrialExpiringSoonMail
. That function will return true
if trial_expiring_mail_sent_at
is not null
. After a mail is sent, a call to rememberHasBeenSentTrialExpiringSoonMail
will set the trial_expiring_mail_sent_at
of the team to the current datetime.
All this will ensure that the command is restartable. If something goes wrong running the command you can simply, after you've solved the problem, run the command again. The checks on trial_expiring_mail_sent_at
will ensure that no team is mailed twice.
The TrialExpiringSoon mailable
You've probably noticed in the code of the command in the previous section that the mail itself is a Mailable. There's really nothing special going there, but I'll include the code for completeness sake.
namespace App\Mail;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class TrialExpiringSoon extends Mailable
{
use Queueable, SerializesModels;
/** @var \App\Team */
public $team;
public function __construct(Team $team)
{
$this->team = $team;
}
public function build()
{
return $this
->subject("Your Oh Dear! trial account will expire soon")
->markdown('mail.trialExpiringSoon', [
'team' => $this->team,
]);
}
}
Testing the command
Laravel has a Mail Fake to easily test if and how mails are sent. In this test we create a team that is on a trial that will expire on 2018-01-31. We use Carbon's setTestNow
function to fake the current time. The tests itself should be pretty self-explanatory.
namespace Tests\Unit\Commands;
use App\Mail\TrialExpiringSoon;
use App\Team;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Mail;
use Laravel\Spark\TeamSubscription;
use Tests\TestCase;
class EmailTeamsWithExpiringTrialsTest extends TestCase
{
/** @var \App\Team */
protected $team;
/** @var \App\User */
protected $user;
public function setUp()
{
parent::setUp();
$this->user = factory(User::class)->create();
$this->team = factory(Team::class)->create([
'trial_ends_at' => Carbon::create(2018, 1, 31),
'owner_id' => function () {
return $this->user->id;
}]);
$this->team->users()->attach($this->user, ['role' => 'admin']);
Mail::fake();
}
/** @test */
public function it_can_send_a_mail_concerning_a_trial_expiring_soon()
{
$this->setNow(2018, 1, 29);
$this->artisan('ohdear:email-teams-with-expiring-trials');
Mail::assertNotSent(TrialExpiringSoon::class);
$this->setNow(2018, 1, 30);
$this->artisan('ohdear:email-teams-with-expiring-trials');
Mail::assertSent(TrialExpiringSoon::class, 1);
Mail::assertSent(TrialExpiringSoon::class, function (TrialExpiringSoon $mail) {
return $mail->hasTo($this->user->email);
});
}
/** @test */
public function it_will_send_the_mail_concerning_a_trial_expiring_soon_only_once()
{
$this->setNow(2018, 1, 30);
$this->artisan('ohdear:email-teams-with-expiring-trials');
Mail::assertSent(TrialExpiringSoon::class, 1);
$this->artisan('ohdear:email-teams-with-expiring-trials');
Mail::assertSent(TrialExpiringSoon::class, 1);
}
/** @test */
public function it_will_not_send_the_mail_concerning_a_trial_expiring_soon_only_if_the_team_has_a_subscription()
{
$this->setNow(2018, 1, 30);
TeamSubscription::create([
'name' => 'default',
'team_id' => $this->team->id,
'stripe_id' => 'my-plan-id',
'stripe_plan' => 'my-plan',
'quantity' => 1,
]);
$this->artisan('ohdear:email-teams-with-expiring-trials');
Mail::assertNotSent(TrialExpiringSoon::class);
}
protected function setNow(int $year, int $month, int $day)
{
$newNow = Carbon::create($year, $month, $day)->startOfDay();
Carbon::setTestNow($newNow);
return $this;
}
}
In closing
I hope you've enjoyed this little tutorial on how to add a trial expiring mail to Laravel Spark. The code in this blog post is the actual code being used at Oh Dear!. So if you want to get the actual trial expiring mail in your mailbox, create an account and wait for 8 days :-)
What are your thoughts on "How to send a "trial expiring soon" mail in Laravel Spark"?