Laravelsaas
Laravelsaas
2 Max Kostinevich
Contents
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
What we are going to build . . . . . . . . . . . . . . . . . . . . 5
Prerequisites . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
Getting the source code . . . . . . . . . . . . . . . . . . . . . 8
Working with the source code . . . . . . . . . . . . . . . . . . 9
Workspace setup . . . . . . . . . . . . . . . . . . . . . . . . . 10
About the author . . . . . . . . . . . . . . . . . . . . . . . . . 10
Contact the author . . . . . . . . . . . . . . . . . . . . . . . . 11
Chapter 1. Planning our app . . . . . . . . . . . . . . . . . . . . . . 12
Designing wireframes . . . . . . . . . . . . . . . . . . . . . . 12
Designing database . . . . . . . . . . . . . . . . . . . . . . . . 14
Chapter 2. Building our app . . . . . . . . . . . . . . . . . . . . . . 17
Creating new app . . . . . . . . . . . . . . . . . . . . . . . . . 17
Preparing views . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Static pages . . . . . . . . . . . . . . . . . . . . . . . 21
Asset compilation . . . . . . . . . . . . . . . . . . . . 23
Authentication pages . . . . . . . . . . . . . . . . . . 29
Building main features . . . . . . . . . . . . . . . . . . . . . . 34
Dashboard . . . . . . . . . . . . . . . . . . . . . . . . 34
Settings . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Payment forms . . . . . . . . . . . . . . . . . . . . . . 55
Payment form frontend . . . . . . . . . . . . . . . . . 67
Accepting payments . . . . . . . . . . . . . . . . . . . 69
Managing payments . . . . . . . . . . . . . . . . . . . 89
Dashboard stats . . . . . . . . . . . . . . . . . . . . . 93
Adding notifications . . . . . . . . . . . . . . . . . . . 100
3
Building SaaS with Laravel
4 Max Kostinevich
Building SaaS with Laravel
Introduction
In this book you’ll learn how to design, build and launch a real-world SaaS
application using Laravel framework. This book will be useful to solopreneurs,
bootstrappers and indie makers who wanted to launch their SaaS business.
I’m going to explain step-by-step each stage of project development - from
designing wireframes to deployment.
You’ll learn how to:
I also included the list of tips and advices which will be useful to you when you
decide to launch you SaaS.
Learn more about this book at maxkostinevich.com/books/laravel-saas
Max Kostinevich 5
Building SaaS with Laravel
As our main goal is to validate our idea and build MVP (minimum-viable-
product) as soon as possible and spend as less time as we can, we’re not going
to implement any subscription-based features. Instead of this, we’re going to
collect some fee (e.g. percent or fixed price) on each payment made through
our app, which will be deducted automatically from the Seller’s account. You
can see the payment flow on the diagram below:
6 Max Kostinevich
Building SaaS with Laravel
On the example above, the Customer paid $10 to the Seller. From this amount
we collected $1.23 as a fee for our application. Additional $0.59 have been
deducted by Stripe as their own fee. As a result, the seller earned $8.18 net
amount ($10 - $1.23 - $0.59 = $8.18).
• Live demo
• Sample payment form
Max Kostinevich 7
Building SaaS with Laravel
Prerequisites
The project we are going to develop in this book is designed for beginner
developers, however I assume that you’re familiar with Laravel framework and
know how to work with Controllers, Models, and run Artisan Commands.
If you’re absolutely new to Laravel, I’d recommend you to check some video
courses first, for example:
• Laracasts
• Codecourse
• Udemy
As we’re a going to use Stripe Connect and Stripe API to handle payments and
payouts, you’ll need to have a Stripe Account. Currently Stripe is available in
34+ countries, so if you’re living in a country where Stripe isn’t yet supported -
don’t worry, you still will be able to create development account with Stripe.
The source code of the PayMe project is included to your purchase. Be sure to
carefully read the README for installation instructions.
Disclaimer
The source code is provided for learning purposes without warranty of any
kind. You’re free to use provided source code (or any parts of it) as you’d like.
Redistribution (i.e. reselling) or sharing (e.g. via public GitHub repository) is
not allowed without prior written permission.
8 Max Kostinevich
Building SaaS with Laravel
For your convenience, all important steps in the source code are marked by
git tags.
You can easily switch between specific tags by using git checkout command.
For example, to switch to tag step-2.1, just type in your terminal:
git checkout step-2.1
That means that the section or chapter has a related tag in the app source
Max Kostinevich 9
Building SaaS with Laravel
code.
Workspace setup
There are no strict requirements which tools to use for local development, you
will be fine with Homestead or Valet. I’d recommend to avoid XAMPP, WAMP
and similar software.
Personally, I use the following setup:
If you’re new to Docker and haven’t worked with it before, I recorded a short
video explaining how to get started with Docker for Laravel development, you
can watch it here.
I also created a ready-to-go Docker template, which is available on the
Github.
Also, if you haven’t worked with Ngrok before, I would recommend you to try
it out! Ngrok allows you expose your local server to the internet over a secure
tunnel. This tool is super-useful for testing OAuth integrations, webhooks,
3rd-party API calls and so on.
10 Max Kostinevich
Building SaaS with Laravel
If you have any questions, ideas, suggestions, or want to report an error, please
email me at [email protected]
For most recent updates, please follow me on Twitter: maxkostinevich
Max Kostinevich 11
Building SaaS with Laravel
Detailed plan is the key to successful results. However, when planning the MVP,
it’s important to keep your requirements list as simple as possible, just because
you can easily got drown in all your notes, ideas, and wanted features.
So first, we need to clearly define what our project does and extract most
essential features of our project and write them down in project specification
file. Based on this file we can create wireframes to get better idea of how our
project will looks like.
Designing wireframes
12 Max Kostinevich
Building SaaS with Laravel
For PayMe I created interactive wireframes using UX App, see the image below.
You can also find these wireframes here.
This is the final version of wireframes for PayMe. Before creating these wire-
frames in UX App, I spent some time to make a few versions on paper.
After we have our wireframes on file, we can proceed to the next step - designing
Max Kostinevich 13
Building SaaS with Laravel
database.
Designing database
It’s important to remember that our database structure may change during
the development process, and that’s totally fine. At that stage our main goal -
is to define starting point from which we can start building something.
For PayMe we can define 3 main models:
• Users (Sellers)
• Forms
• Payments (Sales)
In additional to default Laravel attributes, for each User (Seller) we’ll need to
store their connected Stripe Account ID, their profile picture, and company.
For Payment Forms we’ll need to store related User ID, UID (which will be used
in unique URL), service description, amount and currency.
For Payments we’ll need to store related Form ID, charge ID (Transaction ID
from Stripe), customer name and email, application fee we collected for that
payment, and receipt URL (which is automatically generated by Stripe).
We can also assume the following conditions:
14 Max Kostinevich
Building SaaS with Laravel
As you can see on the image below, the database structure is pretty simple:
Please, note - payment model may not have a user_id, as we can get this ID
from the related Form model. However I decided to store related user_id in
Payments model too, as it will make it much easier to us to calculate statistics
and get the history of user payments.
Useful tools to design the database:
• QuickDatabaseDiagrams
• DBDiagram
Max Kostinevich 15
Building SaaS with Laravel
• Lucidchart
• Draw.io
16 Max Kostinevich
Building SaaS with Laravel
1. Create wireframes.
2. Design the database structure.
3. Prepare application plain HTML templates for each important page/lay-
out.
4. Install fresh Laravel application.
5. Convert plain HTML templates to Laravel views.
6. Start building main functionality.
For this book I will leave the creation of plain HTML templates behind the
scenes, as this is not the main focus of this book and the process is different
for each project. You may find all plain HTML templates in the source code
attached to the book.
I assume that you already prepared your local environment. So let’s create a
new Laravel application by running the following command in our console:
laravel new payme
Max Kostinevich 17
Building SaaS with Laravel
Then let’s update our .env file and update database credentials and APP_URL
variable.
APP_URL=https://payme.ngrok.io
I always force https protocol in my apps. To do this, we’ll just need to add
\URL::forceScheme('https'); to boot() method in our app/Providers/
AppServiceProvider.php:
//...
18 Max Kostinevich
Building SaaS with Laravel
<?php
namespace App;
use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
// ...
}
2. Make sure that users table have email_verified_at column (it’s in-
cluded by default).
3. After that we can pass the verify option to the Auth::routes method
to activate email verification routes:
For email testing on local development I use Mailhog which catches all email
sent by our app to it’s own local tiny SMTP server with a web-based UI. Mailhog
is included by default to my Docker template. To use Mailhog we’ll need to
update email settings in our .env file:
MAIL_DRIVER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
Max Kostinevich 19
Building SaaS with Laravel
keep these files at one place. After we convert our HTML templates to Laravel
views, we’ll remove this folder.
At this stage we can also install some libraries which we’ll use later. I usually
install Guzzle, which helps us to make HTTP requests (e.g. API calls). To install
this library, just run the following command in your console:
composer require guzzlehttp/guzzle
And create failed_jobs table (in this table Laravel will store all information
about our failed jobs) using the following command:
php artisan queue:failed-table
That’s all, at the next step we’ll convert our HTML templates into Laravel views
and customize our authentication pages.
20 Max Kostinevich
Building SaaS with Laravel
Preparing views
Related tag: step-2.x
After we provisioned our new Laravel app, it’s time to convert HTML templates
into Laravel’s blade templates.
Depending on how you decide to organize the structure of your app, your app
may have some static pages (e.g. privacy policy, terms of service, etc.). There
are a few popular ways to do this:
• The app can be hosted on the main domain, in this case all secondary
pages should be served by the app;
• The app can be hosted on a sub-domain, e.g. app.example.com, in this
case all secondary pages could be served using separate CMS (e.g. Word-
Press) on the main domain.
In our case, we’ll have 3 static secondary pages which should be served by our
app:
• Homepage
• Terms of Service
• Privacy Policy
Static pages
Max Kostinevich 21
Building SaaS with Laravel
As we have custom 404 page, let’s also copy 404.html from /resources/
views/_HTML/ to resources/views/errors/ and change the file extension
to .blade.php.
Then, let’s create a new controller called PageController to handle our static
pages. To create a new controller just run the following command in the
console:
You may notice that our controller has been created in app/Http/Controllers
/ folder.
Route::get('/', 'PageController@home')->name('page.home');
Route::get('/terms', 'PageController@terms')->name('page.terms
');
Route::get('/privacy', 'PageController@privacy')->name('page.
privacy');
22 Max Kostinevich
Building SaaS with Laravel
// Terms
public function terms()
{
return view('pages.terms');
}
// Privacy
public function privacy()
{
return view('pages.privacy');
}
}
If you check our new homepage, you may notice that it is rendered with issues,
as all assets (js, css, images) are missing. Let’s fix it now!
Asset compilation
To make our static pages work correctly we need to properly setup and con-
figure the process of asset compilation. This process is depends on how your
HTML templates are actually built and in some cases may take some time to
setup everything correctly. In our case, HTML templates are built using Laravel
Mix, so it will takes us just a few minutes to setup asset compilation process.
Max Kostinevich 23
Building SaaS with Laravel
After that we can build our assets by running the following command:
npm run dev
Then let’s get back to our static pages and update URLs to all missing assets.
In /resources/views/pages/home.blade.php we need to update the follow-
ing:
24 Max Kostinevich
Building SaaS with Laravel
– href="{{ route('login')}}
– href="{{ route('register')}}
As the same layout is used in Terms Of Service and Privacy Policy pages, we
can extract the layout from terms.blade.php:
First, let’s create a new file called static.blade.php in /resources/views/
layouts/ folder.
We can also update the title tag, as we’d like to have an option to pass custom
page title for each page:
<title>{{ config('app.name', 'Laravel') }} - @yield('title')</
title>
Max Kostinevich 25
Building SaaS with Laravel
@extends directive:
@extends('layouts.static')
@section('title', 'Terms of Service')
@section('content')
<div class="container">
<!-- Terms of Service content -->
</div>
@endsection
We can also extract the content of the footer into a component, as it will be the
same in all our pages. To do this, let’s create a footer.blade.php inside of /
resources/views/components/ folder. Then let’s copy the content of footer
tag from static.blade.php to this file. Then we can use this component in
our static layout by using @include directive:
@include('components.footer')
26 Max Kostinevich
Building SaaS with Laravel
Once we created layout file for our static pages and updated our Terms of
Service page, we can update our Privacy Policy page. To do this, we just need
to use @extends directive to tell the view to use static layout and insert the
content of the page by using @section directive:
Max Kostinevich 27
Building SaaS with Laravel
@extends('layouts.static')
@section('title', 'Privacy Policy')
@section('content')
<div class="container">
<!-- Privacy Policy content -->
</div>
@endsection
The last thing we need to do is to update our 404 page. The process will be
the same as we did this for our Homepage. We need to update the following in
/resources/views/errors/404.blade.php:
Alright! We finished with our static pages and can move on by customizing our
authentication pages.
28 Max Kostinevich
Building SaaS with Laravel
Authentication pages
Let’s customize our authentication pages. First, let’s copy login.html, signup
.html, password-reset.html, password-reset-2.html and password-
verify.html from /resources/views/_HTML/ to /resources/views/auth/
.
Then let’s start by customizing our Login page. I prefer to keep original login
.blade.php for reference until the customization is done. So first, we need to
rename original login.blade.php to something like _old_login.blade.php
and then rename login.html to login.blade.php
Then we need to update our new login.blade.php the same as we did this
for our Homepage:
We also need to add appropriate form action and CSRF field to the login form:
Max Kostinevich 29
Building SaaS with Laravel
We also need to update all necessary links to Password Reset and Signup
pages, for example:
30 Max Kostinevich
Building SaaS with Laravel
@if (Route::has('register'))
<div class="col-6">
<span class="small text-muted">Don't have an account
?</span>
<a class="small" href="{{ route('register') }}">Signup
</a>
</div>
@endif
Alright, we just finished customization of our Login page, and we can remove
_old_login.blade.php as we do not need it anymore.
Before moving on with other authentication pages, we can extract the layout
from our Login page, as the same layout will be used across other authentica-
tion pages:
Max Kostinevich 31
Building SaaS with Laravel
@section('title', 'Login')
@section('content')
<!-- Form -->
<form class="js-validate mt-5" method="POST" action="{{
route('login') }}">
@csrf
<!-- ... -->
</form>
@endsection
Alright! Once we finished with our Login page, we can start customizing
our Registration page. First, let’s rename original register.blade.php to
_old_register.blade.php and use this file for reference.
32 Max Kostinevich
Building SaaS with Laravel
• Password Request Form - the form where the user requests a password
reset by entering his email;
• Password Reset Form - the form where the update the password after
clicking a link which has been emailed on previous form.
Max Kostinevich 33
Building SaaS with Laravel
• Do not forget to add a hidden field to the form which will store generated
token:
<input type="hidden" name="token" value="{{ $token }}">
Once we customized authentication pages for our app, we can start building
main features.
Dashboard
34 Max Kostinevich
Building SaaS with Laravel
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
//...
}
Then we need to create a /dashboard route. As we’d like to allow access to our
app to users who verified email, we can apply verified middleware. So at
the end of our routes file /routes/web.php we can add the following lines:
Max Kostinevich 35
Building SaaS with Laravel
Route::group(
[
'middleware' => ['verified'],
],
function () {
Route::get('/dashboard', 'DashboardController@index')
->name('dashboard');
}
);
/**
* Show the application dashboard.
*/
public function index()
{
return view('dashboard.index');
}
36 Max Kostinevich
Building SaaS with Laravel
the following:
Route::get('/home', function () {
return redirect()->route('dashboard');
});
/**
* Where to redirect users after registration.
*
* @var string
*/
protected $redirectTo = '/dashboard';
A> A small note: If built-in Email Verification feature doesn’t work properly, for
Max Kostinevich 37
Building SaaS with Laravel
example - you receive “Token Expired” error when clicking on Email Verification
link, than you need to apply a quick fix: Just go to TrustProxies.php middle-
ware located in /app/Http/Middleware/ and update $proxies attribute to
$proxies = '*';
38 Max Kostinevich
Building SaaS with Laravel
Then we can extract app layout and header component from our Dashboard
view as shown on the picture below:
Max Kostinevich 39
Building SaaS with Laravel
40 Max Kostinevich
Building SaaS with Laravel
@if ($errors->any())
<div class="alert alert-danger" role="alert">
<div class="font-weight-bold">Oops! Please, fix the
following errors:</div>
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if (session('status'))
<div class="alert alert-success" role="alert">
{{ session('status') }}
</div>
@endif
We can also add a guest middleware to our homepage, as we’d like to redirect
all logged-in users straight to Dashboard. To do so just go to PageController
controller and add __construct method:
//...
Max Kostinevich 41
Building SaaS with Laravel
Settings
Do not forget to assign auth middleware to this controller the same way we
did this for DashboardController:
class SettingsController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
//...
}
Then let’s add a edit() method to our SettingsController which will used
to render our settings form. At this step we can also create a placeholder for
our update():
42 Max Kostinevich
Building SaaS with Laravel
/**
* Show the settings page.
*/
public function edit()
{
return view('settings.edit');
}
/**
* Update user settings.
*/
public function update(Request $request)
{
// @TODO: save user's settings
return true;
}
After this we can add our /settings route to our /routes/web.php file just
below /dashboard route:
Max Kostinevich 43
Building SaaS with Laravel
Route::group(
[
'middleware' => ['verified'],
],
function () {
// Dashboard
//...
// Settings
Route::get('/settings', 'SettingsController@edit')->
name('settings.edit');
Route::patch('/settings', 'SettingsController@update')
->name('settings.update');
});
As we’d like to store optional user’s company name, we need to add a column
for this to our Users table. To do this, we need to run the following command
in our console:
We can specify a database table name we’d like to use by passing --table
option to our artisan command.
Let’s call our new column company and place it after user name. Our migration
will looks as follow:
44 Max Kostinevich
Building SaaS with Laravel
After we created our migration, we can start working on settings form itsef.
Max Kostinevich 45
Building SaaS with Laravel
46 Max Kostinevich
Building SaaS with Laravel
/**
* Update user settings.
*/
public function update(Request $request)
{
$user = auth()->user();
$request->validate([
'name' => 'required',
'email' => 'required|email|unique:users,email,' .
$user->id
]);
$user->name = $request->input('name');
$user->email = $request->input('email');
$user->company = $request->input('company');
$user->save();
return redirect()
->back()
->with('status', 'Your settings have been updated
successfully.');
}
}
Notice a little trick we used in form validation - this way we can be sure all
users have unique emails.
Next, let’s add allow user to upload their avatar or profile picture:
First, we need to add enctype="multipart/form-data"to our settings form,
Max Kostinevich 47
Building SaaS with Laravel
this way we will be able to accept user uploads. Then we need to change
upload field name to avatar. But wait, we do not have such field in our users
table, so let’s fix this by creating another migration. As you can remember, we
can do this by following command:
48 Max Kostinevich
Building SaaS with Laravel
Max Kostinevich 49
Building SaaS with Laravel
//...
/**
* Update user settings.
*/
public function update(Request $request)
{
$user = auth()->user();
$request->validate([
'name' => 'required',
'email' => 'required|email|unique:users,email,' .
$user->id,
'avatar' => 'image|mimes:jpeg,jpg,png,gif|max:1024
'
]);
$user->name = $request->input('name');
$user->email = $request->input('email');
$user->company = $request->input('company');
if ($request->file('avatar')) {
$avatar = $request->file('avatar')->store('uploads
', 'public');
$user->avatar = $avatar;
}
$user->save();
return redirect()
->back()
->with('status', 'Your settings have been updated
successfully.');
}
}
50 Max Kostinevich
Building SaaS with Laravel
As you can see, uploading images in Laravel can be done within just a few lines
of code: first, we validate our avatar field and make sure that uploading file is
an image. Then, we store this file in our /uploads/ directory.
The last thing we need to do - is to show the uploaded avatar on our settings
form, if no avatar uploaded - let’s show a placeholder (a first letter of user’s
name):
We also wanted to allow users to delete their avatars, so let’s do this! First,
let’s create a new deleteAvatar() method in our SettingsController:
Max Kostinevich 51
Building SaaS with Laravel
/**
* Delete user avatar
*/
public function deleteAvatar(Request $request)
{
$user = auth()->user();
if ($user->avatar) {
File::delete('storage/' . $user->avatar);
$user->avatar = '';
$user->save();
}
return redirect()
->back()
->with('status', 'Your avatar has been updated
successfully.');
}
}
As you can see, the method is pretty simple: we just check if current user have
an avatar, and if so - we just delete this file and set avatar to empty string.
Route::delete('/settings/avatar', '
SettingsController@deleteAvatar')->name('settings.
delete_avatar');
52 Max Kostinevich
Building SaaS with Laravel
But how we actually submit this form? We can do this by adding simple
javascript to Delete button on our main form:
Great! We almost done with our settings form. The one more thing we need
to take care of - is provide an option to user to change the password. Our
password update form contains 3 fields: current password, new password and
confirm new password.
Max Kostinevich 53
Building SaaS with Laravel
/**
* Update user password
*/
public function updatePassword(Request $request)
{
$user = auth()->user();
$request->validate([
'old_password' => ['required', 'required_with:
password',
function ($attribute, $value, $fail) use (
$user) {
if (!password_verify($value, $user->
password)) {
return $fail(__('The current password
is incorrect.'));
}
}
],
'password' => 'required|required_with:old_password
|string|min:6|confirmed',
]);
$user->password = bcrypt($request->input('password'));
$user->save();
return redirect()
->back()
->with('status', 'Your password has been updated
successfully.');
}
}
54 Max Kostinevich
Building SaaS with Laravel
Alright, we’re finished settings form and can move on! Let’s proceed with
making payment forms.
Payment forms
Using the command above, we create a Form model, flag m creates a migration
for this model, flag c creates a controller for this model, and flag r tells artisan
that we need a resource controller.
Max Kostinevich 55
Building SaaS with Laravel
For each payment form we need to know the owner of that form, so we need to
store a user_id, we also need some kind of unique identifier (uid) which will
be used as a permalink for that particular form. You can see entire database
schema in “Designing database” chapter. Let’s add all necessary columns to
our migration file:
56 Max Kostinevich
Building SaaS with Laravel
Then we need to add relations to our models, so we’ll be able to get all User
forms:
Max Kostinevich 57
Building SaaS with Laravel
// User forms
public function forms()
{
return $this->hasMany('App\Form');
}
}
And vice versa - we need to add a user() method in order to be able to get an
owner for a particularForm
58 Max Kostinevich
Building SaaS with Laravel
Route::group(
[
'middleware' => ['verified'],
],
function () {
//...
// Forms
Route::get('/forms', 'FormController@index')->name('
forms.index');
Route::get('/forms/create', 'FormController@create')->
name('forms.create');
Route::post('/forms/create', 'FormController@store')->
name('forms.store');
Route::get('/forms/{uid}', 'FormController@edit')->
name('forms.edit');
Route::patch('/forms/{form}', 'FormController@update')
->name('forms.update');
Route::delete('/forms/{uid}', 'FormController@destroy'
)->name('forms.destroy');
});
Then let’s prepare our views. First, we need to copy forms.html from
/resources/views/_HTML/ to /resources/views/forms/ and rename it
to index.blade.php. Then let’s apply app layout to this view and update
index() method in our FormController:
Max Kostinevich 59
Building SaaS with Laravel
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$forms = auth()->user()->forms()->orderBy('id', 'desc'
)->paginate(25);
return view('forms.index', compact('forms'));
}
}
We’ll get back to index.blade.php file a little bit later. Meanwhile let’s pre-
pare form to create and edit our payment forms: copy form-new.html from
/resources/views/_HTML/ to /resources/views/forms/ and rename it to
edit.blade.php. Then apply app layout and update form method to POST and
form action to {{ $form->id ? route('forms.update', $form): route(
'forms.store')}}. As we’re going to use the same form for create and update
our payment forms, we can use the following little trick here:
@if($form->id)
<input type="hidden" name="_method" value="patch">
@endif
60 Max Kostinevich
Building SaaS with Laravel
follow:
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$form = new Form();
$form->user_id = auth()->user()->id;
return $this->update($request, $form);
}
//...
}
In the method above we create a new Form, assign current logged-in user to it
and then call update() method, which looks as follow:
Max Kostinevich 61
Building SaaS with Laravel
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param \App\Form $form
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Form $form)
{
$request->validate([
'description' => 'required',
'amount' => 'required|numeric|min:1',
'currency' => 'required',
]);
$form->description = $request->input('description');
$form->amount = (float)str_replace(',', '', $request->
input('amount')) * 100;
$form->currency = $request->input('currency');
$form->is_active = $request->input('is_active');
$form->uid = $form->uid ?? uniqid();
$message = $form->id ? 'Payment form has been updated
successfully' : 'Payment form has been created
successfully';
$form->save();
return redirect()
->route('forms.edit', $form)
->with('status', $message);
}
//...
}
62 Max Kostinevich
Building SaaS with Laravel
First, we validate incoming data, then we update form attributes. If this is a new
form - we also generating an uid by using uniqid() function. Then we store
that form and redirect back to form edit view with a successful message.
//...
}
Now let’s get back to our forms/index.blade.php view and show the list of
user’s forms. As we’re already passing user forms to the view via the index()
method in FormController, we can use $forms variable to show the forms.
We can use for, foreach, while or forelse statements for this purpose. Let’s
use forelse statement, and show No records found message if user haven’t
created any forms yet:
Max Kostinevich 63
Building SaaS with Laravel
<tbody class="font-size-1">
@forelse($forms as $form)
<tr>
<td class="align-middle font-weight-normal">
<a href="#" target="_blank" class="d-block text-{{
$form->is_active ? 'success' : 'muted' }}
small">
<span class="fas fa-circle small mr-1"></span>
{{ $form->uid }}</a>
</td>
<td class="align-middle">
<span class="d-block">{{ $form->
amountFormattedWithCurrency() }}</span>
<span class="d-block text-muted small">{{ $form->
description }}</span>
</td>
<td class="align-middle">
<span class="d-block">2,390.00 USD</span>
<a href="#" class="link-muted small">10 payments</
a>
</td>
<td class="align-middle">
<a href="{{ route('forms.edit', $form->uid) }}"
class="small mr-3"><span class="fas fa-edit"></
span> Edit</a>
<a href="#" class="small text-danger"></span>
Delete</a>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="align-center">
<strong>No records found</strong><br>
</td>
</tr>
@endforelse
</tbody>
64 Max Kostinevich
Building SaaS with Laravel
We’ll add all missing information such as link to the form frontend, total pay-
ments and transactions amount in the next steps.
Max Kostinevich 65
Building SaaS with Laravel
/**
* Remove the specified resource from storage.
*/
public function destroy($uid)
{
$form = Form::where('id', $uid)
->orWhere('uid', $uid)
->firstOrFail();
if (auth()->user()->id != $form->user_id) {
return abort(401);
}
$form->delete();
return redirect()
->route('forms.index')->with('status', 'Payment
form has been deleted successfully');
}
}
As you can see, this method is pretty simple: first, we’re trying to find a form
with provided uid, then we’re checking if logged-in user is the owner of that
form (in other words - if current logged-in user have rights to delete that form).
If everything is ok - we’re deleting the form and redirecting user back to forms
.index view with some message.
66 Max Kostinevich
Building SaaS with Laravel
We’re doing a little trick here: we added a non-visible form with id property,
we also added a javascript event handler to Delete link to trigger that form.
Alright, we almost finished with payment forms! A few things we need to do:
After that we can proceed with preparing a frontend view for the payment
form.
Let’s prepare view for our customer-facing payment form. First, we need to
copy payment-form.html from /resources/views/_HTML/ to /resources/
views/payment-form/ and rename it to show.blade.php.
Max Kostinevich 67
Building SaaS with Laravel
Then let’s finish our payment form view, we need to update paths to our assets,
68 Max Kostinevich
Building SaaS with Laravel
• Update payment form data (user name, avatar, payment amount, pay-
ment description, etc).
Accepting payments
In this chapter we are going to add payment processing feature to our app.
First, we need to install stripe-php package, to do this just enter the following
command to your console:
composer require stripe/stripe-php
Max Kostinevich 69
Building SaaS with Laravel
As we’re going to use Stripe Connect to make payouts, we need to store Stripe
Account ID for each of our users. Let’s create a new migration using the follow-
ing command:
And then add a new stripe_account_id field, so our migration will look as
follows:
70 Max Kostinevich
Building SaaS with Laravel
Do not forget to run run this migration using php artisan migrate com-
mand.
Then, let’s create a new config file to store our Stripe credentials. To do this
just create a new stripe.php file in /config directory with the following con-
tent:
Max Kostinevich 71
Building SaaS with Laravel
<?php
// Stripe Settings
return [
// Stripe Publishable Key
'publishable_key' => env('STRIPE_PUBLISHABLE_KEY', ''),
// Stripe Secret Key
'secret' => env('STRIPE_SECRET', ''),
// Stripe Connect Client ID
'client_id' => env('STRIPE_CLIENT_ID', ''),
];
Now we can use config() helper function to get the value of config variable, for
example, to get a Stripe Client ID we would call config('stripe.client_id'
). As all secret keys and passwords should be stored in .env file, we are using
env() helper function to pass actual value from our .env file.
To get your Stripe API keys login to your Stripe Dashboard (or create a new
account if you haven’t registered yet) and go to Developers -> API keys on
the left sidebar menu. Please, note: If you want to get API keys for your devel-
opment (test) application, do not forget to switch View Test Data option as
shown on the image below:
To get Stripe Client ID, you need to go to Settings -> Connect settings as
shown on the image below:
72 Max Kostinevich
Building SaaS with Laravel
Next, let’s create a Service Provider where we bind our Stripe API object to
singleton. Service Providers are usually used to instantiate API wrappers,
Max Kostinevich 73
Building SaaS with Laravel
For example, let’s say we’re building application which interacts with Shopify
via API. Instead of instantiating Shopify API Wrapper object each time, we can
create Shopify Service Provider and instantiate Shopify API wrapper only once,
it may looks as follows:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
//...
74 Max Kostinevich
Building SaaS with Laravel
<?php
namespace App\Http\Controllers;
//...
class OrderController extends Controller
{
public function index()
{
$shopify = resolve('ShopifyAPI');
$orders = $shopify->getOrders();
return orders;
}
//..
}
Visit laracasts.com if you want to learn more about Service Providers and
Service Containers, Jeffrey Way did a great job explaining them.
Max Kostinevich 75
Building SaaS with Laravel
<?php
namespace App\Providers;
//...
//...
}
As Stripe API Wrapper uses static methods, we do not need to store instantiated
object in a variable:
<?php
//...
$charge = \Stripe\Charge::create(
[
'amount' => 1000,
'currency' => 'USD',
]
);
Then we need to create routes for Stripe oAuth in our routes/web.php file:
76 Max Kostinevich
Building SaaS with Laravel
Route::group(
[
'middleware' => ['verified'],
],
function () {
//...
// Stripe oAuth
Route::get('/stripe/oauth', '
StripeOAuthController@oauth')->name('stripe.oauth')
;
Route::get('/stripe/authenticate', '
StripeOAuthController@authenticate')->name('stripe.
authenticate');
Route::post('/stripe/deactivate', '
StripeOAuthController@deactivate')->name('stripe.
deactivate');
//...
});
Max Kostinevich 77
Building SaaS with Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Stripe;
Max Kostinevich 79
Building SaaS with Laravel
80 Max Kostinevich
Building SaaS with Laravel
Then let’s create Payment model and migration for it by using the following
command:
Then let’s add all needed columns to our payment migration, so it will looks
as follow:
Max Kostinevich 81
Building SaaS with Laravel
82 Max Kostinevich
Building SaaS with Laravel
Max Kostinevich 83
Building SaaS with Laravel
84 Max Kostinevich
Building SaaS with Laravel
Route::post('/p/{uid}', 'PaymentFormController@store')->name('
form.store');
86 Max Kostinevich
Building SaaS with Laravel
A few notes:
<?php
// Calculate application fee (in cents) for the payment
function get_payment_fee($amount)
{
// Get 5% + 50 cents
$fee = $amount * 0.05 + 50;
$fee = round($fee);
return $fee;
}
Do not forget to register this file in composer.json, to do this just add autoload
property:
"autoload": {
"psr-4": {
"App\\": "app/"
},
"files": [
"app/helpers.php"
],
//...
}
Great! In next step we’ll add an option to manage payments through the
dashboard.
88 Max Kostinevich
Building SaaS with Laravel
Managing payments
Now we would like to allow our users to see received payments and make
refunds.
Max Kostinevich 89
Building SaaS with Laravel
Next, let’s add an option to refund a specific payment. This feature will al-
low our users to make decision about refunds on their own, and make it
easier for us to maintain the project. We’ll be using update() method in
PaymentController we created earlier. In this method we make an attempt to
make a refund throught Stripe API and, in case of success, mark the payment
as refunded:
90 Max Kostinevich
Building SaaS with Laravel
//...
public function update(Request $request, Payment $payment)
{
if (auth()->user()->id != $payment->user_id) {
return abort(401);
}
try {
$refund = \Stripe\Refund::create([
'charge' => $payment->charge_id,
'refund_application_fee' => true,
'reverse_transfer' => true
]);
$payment->is_refunded = 1;
$payment->save();
return redirect()
->back()->with('status', 'Refund has been
processed successfully');
} catch (Exception $e) {
logger()->error($e->getMessage());
}
return redirect()
->back()->withErrors('There was an error
encountered.');
}
Max Kostinevich 91
Building SaaS with Laravel
view and add a “Refund” link to each payment record (the same way we did it
to handle payment form deletion):
<td class="align-middle">
<a href="{{ $payment->receipt_url }}" target="_blank"
class="text-primary small mr-3"><span class="fas fa-
receipt"></span> Receipt</a>
@if($payment->is_refunded)
<span class="small text-muted"><span class="fas fa-
circle small"></span> Refunded</span>
@else
<a href="#" class="text-danger small" onclick="if(confirm
('Refund this payment?')){document.getElementById('
refund-entity-{{ $payment->id }}').submit();return
false;}"><span class="fas fa-redo-alt"></span> Refund</
a>
<form id="refund-entity-{{ $payment->id }}" action="{{
route('payments.update', $payment) }}" method="POST">
<input type="hidden" name="_method" value="PATCH">
@csrf
</form>
@endif
</td>
92 Max Kostinevich
Building SaaS with Laravel
<td class="align-middle">
<span class="d-block">{{ amountFormattedWithCurrency($form
->payments->sum('amount'), $form->currency) }}</span>
<a href="{{ route('form.payments.index', $form->uid) }}"
class="link-muted small">{{ $form->payments()->count()
}} {{ Str::plural('payment', $form->payments()->count()
) }}</a>
</td>
and do not forget to update dashboard view and add a link to all payments in
/resources/views/components/header.blade.php.
Dashboard stats
After we added payments handling and management, we can add some pay-
ment statistics to our dashboard. As payments can be made in different cur-
rencies, we want to convert all payments to USD.
First, let’s add all available currencies to our config/app.php file:
Max Kostinevich 93
Building SaaS with Laravel
return [
//...
// Available currencies
'currencies' => [
'usd',
'eur',
'cad',
'aud',
],
//...
];
Now we can get the list of all available currencies using the config function,
for example: $currencies = config('app.currencies');
Then we need to use this currency list in Edit Payment Form view located in
/resources/views/forms/edit.blade.php:
<div class="col-5">
<select name="currency" class="form-control" {{ $form->id
? 'disabled' : '' }}>
@foreach(config('app.currencies') as $currency)
<option value="{{ $currency }}" {{ old('currency',
$form->currency) == $currency ? 'selected' : '
' }}>{{ strtoupper($currency) }}</option>
@endforeach
</select>
</div>
94 Max Kostinevich
Building SaaS with Laravel
So, first, sign up at https://currencylayer.com/ and obtain new API key. Then
create a new configuration file currencylayer.php in /config/ directory with
the following content:
// Currencylayer Settings
return [
// Currencylayer API Key
// Obtain your free key at https://currencylayer.com
'api' => env('CURRENCYLAYER_API_KEY', ''),
];
Okay, now we can get currency exchange rate by making a request to Cur-
rencyLayer API. It’s a good idea to store received currency exchange rate in
the cache of our application and refresh it once a day. It will allow us to sig-
nificantly reduce number of API calls to CurrencyLayer and reduce our costs.
To do this, we can add the following functionality to boot method of our
AppServiceProvider:
Max Kostinevich 95
Building SaaS with Laravel
96 Max Kostinevich
Building SaaS with Laravel
CurrencyLayer API allow us to get exchange rates for multiple currencies within
one API call, to do this we just need to pass a comma-separated list of needed
currencies.
As we want to include only non-refunded payments to our stats, we can add
NotRefunded scope to Payment model. It’s a good idea to also add a Refunded
scope:
class Payment extends Model
{
//...
Now we can get user’s not refunded payments using the following query:
$payments = $user->payments()->notRefunded()->get().
Now we can add getStats method to User model which should return an
array of needed metrics for our stats. We need the following metrics:
• Total sales;
• Total net earnings;
• Sales in last 30 days;
• Number of payments made within last 30 days;
Max Kostinevich 97
Building SaaS with Laravel
98 Max Kostinevich
Building SaaS with Laravel
/**
* Show the application dashboard.
*/
public function index()
{
$payments = auth()->user()->payments()->orderBy('id',
'desc')->limit(10)->get();
$stats = auth()->user()->getStats();
<div>
<span>{{ amountFormattedWithCurrency($stats['netEarnings'
]) }}</span>
<span>Total net earnings</span>
</div>
Adding notifications
After we implemented all necessary functionality to our app, we can add email
notifications. We want to send email notifications to our users in the following
cases:
etc.). Each time OrderCreated event is dispatched, you want to perform num-
ber of actions (e.g. send email to the customer, send order information to
fulfillment center, send notification to your Slack channel, etc.), so for each ac-
tion you create an Event Listener which listen for specific event and perform
all needed tasks.
In our case, we’ll have the following events and event listeners:
• Event: PaymentCreated
• Event: PaymentRefunded
And then run php artisan event:generate command in the console to au-
tomatically generate all necessary PHP classes.
Then we need to dispatch these events using event() function, let’s dispatch
PaymentCreated event in PaymentFormController:
It’s a good idea to use Queues to handle event listeners and notifications, as
usually these tasks may take some time to perform all needed actions. To
make event listener queueable, we need to add ShouldQueue interface to
SendPaymentConfirmationNotification, so it will look as follows:
Sometimes, queued tasks are failing, so we need a way to repeat failing tasks.
Luckily, Laravel provides such functionality out of the box. First, we need to
create failed_jobs table and run migration:
php artisan queue:failed-table
All failed jobs will be stored in this table, to run failed job we’ll need to run
php artisan queue:work command.
Small improvements
Before starting making a Master Dashboard for our application, let make a few
small improvements:
• Let’s remove _HTML folder from our views directory, as we do not need
these files anymore;
• Let’s prevent payment form to be appear if user’s Stripe account is not
connected;
• It’s also a good idea to enable soft deleting for payment forms and users;
• Let’s show a notification on the dashboard, if Stripe account is not con-
nected;
In this chapter we’re going to build a really simple Master Admin, where we
can see the list of all our users with some metrics (like Lifetime Earnings) and
ability to delete/disable certain users. Master Admin is not required for MVP,
you can always build it later. If you’re building an MVP, I would not recommend
to spend a lot of time on building Master Admin, as on early stages it’s better
to focus on core features of your app.
First, we need a way to determine if current logged in user can have an access
to Master Admin. There are a lot of options to do this (e.g. adding a Role-
management system), however, we’d like to keep things as simple as possible.
And in our case we’ll have only one admin user. So let’s add a new admin config
with the following content:
<?php
// Master Admin Settings
return [
// Master Admin email
'email' => env('ADMIN_EMAIL', '')
];
Then, in our .env file we need to set ADMIN_EMAIL variable. Then we need to
add isAdmin method to User model:
class User
{
//...
// Is Master Admin
public function isAdmin()
{
return in_array($this->email, [
config('admin.email')
]);
}
//...
}
This method returns true if user’s email match with the admin email, defined
in config file we created.
Then we need to create a new IsAdmin middleware using the following com-
mand: php artisan make:middleware IsAdmin. handle() method of our
middleware may looks as follow:
class IsAdmin
{
public function handle($request, Closure $next)
{
if (auth()->check() && auth()->user()->isAdmin()) {
return $next($request);
}
return redirect()->route('dashboard');
}
}
Then we need to define new routes in /routes/web.php for our master ad-
min:
Route::group(
[
'middleware' => ['auth', 'is_admin'],
'prefix' => 'admin',
'as' => 'admin.',
'namespace' => 'Admin',
],
function () {
// Users
Route::get('/users', 'UserController@index')->name('
users.index');
Route::delete('/users/{user}', 'UserController@destroy
')->name('users.destroy');
Route::patch('/users/{id}', 'UserController@restore')
->name('users.restore');
}
);
As we’re using Laravel Horizon to manage queues, we also need to allow our
master admin to log in to Horizon instance. To do so we need to modify gate()
method in /app/Providers/HorizonServiceProvider.php as follows:
class HorizonServiceProvider extends
HorizonApplicationServiceProvider
{
//...
protected function gate()
{
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, [
config('admin.email')
]);
});
}
}
The last thing we need to do - is to add links to our master admin and Horizon
instance to our layouts/app.blade.php view file. Do not forget to make these
links visible to master admin only, using user()->isAdmin() method:
<!-- Navigation -->
<!-- ... -->
@if(auth()->user()->isAdmin())
<li class="nav-item u-header__nav-item">
<a class="nav-link u-header__nav-link" href="/horizon/"
target="_blank">Horizon</a>
</li>
<li class="nav-item u-header__nav-item">
<a class="nav-link u-header__nav-link" href="{{ route('
admin.users.index') }}">Administration</a>
</li>
@endif
<!-- ... -->
Personally, I prefer to use DigitalOcean for hosting, Forge for server manage-
ment and Envoyer for zero-downtime deployments. If a short period of down-
time isn’t crucial to your app, you may use Forge without Envoyer for deploy-
ment.
If you want to learn more about Forge and Envoyer, I would recommend you
to check these awesome series on Laracasts:
• Learn Forge
• Learn Envoyer
Another great tool for deployment and server management is Ploi, created
by Dennis Smink. Ploi includes all features Forge and Envoyer has, and cost a
little bit less than Forge and Envoyer in total.
If you’re beginner and do not know a lot about server management, you may
take a look at Cloudways, they offer easy-to-use managed hosting platform
for Laravel apps.
Laravel Vapor - is entire tool in Laravel eco-system, it allows you to host Laravel
application in Serverless infrastructure. Serverless approach have it’s own
pros and cons, and out of scope of this book.
It’s also a good idea to keep deployment instructions in Readme file of your
project.
Summary
Okay, we finished and launched our MVP. I deliberately left small flaws in app
source code, so you can take some practice in code refactoring. For example,
one of the clear areas for refactoring is amountFormattedWithCurrency()
function, as this function is currently defined twice - in Payment model and in
our helper file.
You may also add some new features, for example:
• Add support of digital downloads to allow your users to sell digital con-
tent;
• Add support for recurring payments;
• Add an option which allows your users to embed payment forms to their
websites as javascript widget;
• ..and more!
In this chapter I’ll share my thoughts and useful tips about making and running
Sofware-as-a-Service applications.
Types of SaaS
I would distinct two different types of SaaS: - Standalone SaaS - This is type
of SaaS, which is not depending on any other service.
Both of types have their own pros and cons. For example, making a standalone
SaaS gives you more freedom and flexibility, as you’re not depending on other
product. On other side, it could be harder to get your first users of standalone
SaaS, as many established products have their own marketplace where devel-
opers like you can publish extensions (plugins or apps) for that big product.
Making an extension for established product usually requires you to follow
some design guidelines, rules, and marketplace terms. However, it gives you
an access to existing and loyal customers who might be happy to use your
extension (and pay you!).
If you’re willing to learn more about different aspects of running SaaS, I would
recommend to join SaaS Club by Omer Khan. SaaS Club provides a lot of useful
resources, such as group coaching sessions, a huge content library, access to
private community and expert master classes.
If you’re active on Twitter, you probably heard about Reilly Chase and his story.
So if you’re working on your project while keeping your daily job, it’s a good
idea to get a legal advice and consult your lawyer to make sure you do not
violate the terms of your employment contract.
You may also consider to create a new company for your new project. De-
pending on your requirements, there are several services allowing you to open
company remotely. Most of popular options are:
Before opening a company, it’s a good idea to consult with your accountant
regarding all important questions (e.g. Tax/VAT handling, local law compliance,
etc).
Recommended books
There are a lot of books about making and running software projects, I’ll just
share my Top-3:
Afterword
Thank you for reading this book, I hope you found it useful!
If you have any questions, found a typo or just want to provide a feedback,
feel free to shoot me a tweet at maxkostinevich or email me at hello@maxko
stinevich.com
— Max Kostinevich