Saleem.dev
Published on

Laravel Jetstream Demo - Let's Build a SaaS URL Shortener

Authors
Table of Contents

Recently Laravel 8 has been released with many features and improvements including Laravel Jetstream. To simplify what is Jetstream, let me ask you a question! are you familiar with default Laravel auth scaffolding (remember php artisan ui bootstrap --auth)?, if so, then Jetstream is substantially the father of that :)

Laravel Jetstream is a beautifully designed application scaffolding for Laravel. Jetstream provides the perfect starting point for your next Laravel application and includes login, registration, email verification, two-factor authentication, session management, API support via Laravel Sanctum, and optional team management.

Overview

In this article, we'll try together to build a simple SaaS application that performs the known logic, shortening URLs. However, I assume you have a basic knowledge of Livewire and Laravel to make the best out of this article.

url-shortener-layout.jpg

Let's first illustrate the big picture of the project into three main steps:

  1. Install Laravel Jetstream using Laravel installer with Livewire stack selected.
  2. Explore the initial setup of Jetstream and build the dashboard needed for our app's users.
  3. Simulate users payments and upgrade plan.

So without further ado, let’s start building. . .

Step 1: Installing Jetstream

Note: make sure you update the global Laravel installer to the latest version:

composer global remove laravel/installer
composer global require laravel/installer

To create a new project with Laravel Jetstream installed, run this command:

laravel new url-shortener --jet --stack=livewire

Once installed, run this command to install and compile the assets needed:

npm i && npm run dev

Finally, make sure you create a database for the application, let's call it jetstream, then run the migrations:

php artisan migrate

Perfect, now open the app in your browser php artisan serve or valet, then register for a new user, your default dashboard will look like this:

laravel jetstream default dashboard

Also, go to the default profile page, you'll see the default actions that you can do for free are:

  1. Profile information and photo
  2. Update password
  3. Two factor authentication
  4. Manage browser sessions
  5. Delete account

Amazing, right? πŸ€“


Step 2: Building the app

Note, we are not handling any performance-related topics on the large scale level, instead, I expect you to learn mainly the integration part of the services and tools that Laravel and Laravel Jetstream provide.

Consider we want to build the URL shortener app with the following user stories:

  1. A user can register for free and add up to 10 URLs with 10 hits/min API rate limits for each URL.
  2. A user can purchase a premium plan which allows them to add up to 100 URLs with 100 hits/min API rate limits for each URL.

To accomplish this, firstly, we need to prepare the database with the following updates:

  1. Update users migration and add the following:
<?php
..
Schema::create('users', function (Blueprint $table) {
    ..
    $table->string('plan')->default('free'); // you might do user plan in better way but okay for this tutorial
    ..
});
..
  1. Create the URL model:
php artisan make:model Link -m

Then, update the migration table to include the following fields:

...
Schema::create('links', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('user_id')->index();
    $table->text('original');
    $table->string('shortened'); // the generated short version 
    $table->unsignedBigInteger('views')->default(0); // will store the number of hits the url gets (no ideal for large production but here is fine)
    $table->timestamps();
});
...

Add the relation between User and Link models:

// in User.php
protected $fillable = [
    ..
    'plan'
];
public function links()
{
    return $this->hasMany(Link::class);
}

// in Link.php
public function user()
{
    return $this->belongsTo(User::class);
}

Finally, rerun the migration to take effect:

php artisan migrate:fresh
  1. Start building the dashboard component using Livewire:

We need three main components, one is to create the link and generate the short version, we call it CreateLink.

php artisan make:livewire CreateLink

The second one will be used to list down all the links in a table and have basic analytics about how many times this URL has been hit or viewed, we'll call it Analytics.

php artisan make:livewire Analytics

Finally, the last component will be used in the profile page to simulate the user purchasing the pro version of the app, let's call it

php artisan make:livewire UpgradeUserPlanForm

Great! so let's start building the UI for each one:

Please note that we'll use some of the UI components that Jetstream provides, therefore, I advise you to publish the components to understand them better using this command:

php artisan vendor:publish --tag=jetstream-views
  1. Write the following code in the CreateLink component:
<div>
    <x-jet-validation-errors class="mb-4" />

    <div class="w-full flex flex-col sm:justify-center items-center bg-gray-100">
        <div class="w-full px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
            <form wire:submit.prevent="submit">
                <div>
                    <x-jet-label value="Original URL" />
                    <x-jet-input class="block mt-1 w-full" type="text" name="original" wire:model="original" :value="old('original')" required autofocus />
                </div>

                <div class="flex justify-end">
                    <x-jet-button class="mt-4 bg-green-500">Shorten</x-jet-button>
                </div>
            </form>
        </div>
    </div>
</div>

And the back-end for the CreateLink component:

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;

class CreateLink extends Component
{
    use AuthorizesRequests;

    public $original;
    protected $linkLength = 6;

    public function submit()
    {
        $this->validate(['original' => 'required|url']);

        $this->authorizeCreatingLink();

        auth()->user()->links()->create([
            'original' => $this->original,
            'shortened' => $this->generateShortLink(),
        ]);

        $this->emit('linkCreated');
        $this->original = '';
    }

    protected function authorizeCreatingLink()
    {
        $user = auth()->user();
        abort_if($user->plan == 'free' && $user->links()->count() == 3, 403, 'Please upgrade your plan to continue using our service!');
        abort_if($user->plan == 'premium' && $user->links()->count() == 100, 403, 'Please upgrade your plan to continue using our service!');
        abort_if($user->links()->where('original', $this->original)->count(), 403, 'You already posted this link before!');
    }

    protected function generateShortLink()
    {
        return substr(md5(time()), 0, $this->linkLength);
    }

    public function render()
    {
        return view('livewire.create-link');
    }
}
  1. Then, let's build the second UI component Analytics:
<div class="flex flex-col mt-6" wire:poll>
  <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
    <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
      <div class="shadow overflow-hidden border-b border-gray-200 bg-white sm:rounded-lg">
        @if($links->count())
            <table class="min-w-full divide-y divide-gray-200">
            <thead>
                <tr>
                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                    Original URL
                </th>
                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                    Short version
                </th>
                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                    Views
                </th>
                <th class="px-6 py-3 bg-gray-50"></th>
                </tr>
            </thead>
            <tbody class="bg-white divide-y divide-gray-200">
                @foreach($links as $link)
                    <tr>
                        <td class="px-6 py-4 whitespace-no-wrap">
                            {{ substr($link->original, 0, 50) }}@if(strlen($link->original) > 50)...@endif
                        </td>
                        <td class="px-6 py-4 whitespace-no-wrap">
                            <a href="{{ route('links.show', ['link' => $link->shortened]) }}" target="__blank" class="px-2 inline-flex leading-5 font-semibold rounded-full bg-green-100 text-green-800">
                                {{ route('links.show', ['link' => $link->shortened]) }}
                            </a>
                        </td>
                        <td class="px-6 py-4 whitespace-no-wrap">
                            {{ number_format($link->views) }}
                        </td>
                        <td class="px-6 py-4 whitespace-no-wrap text-right text-sm leading-5 font-medium">
                            <button wire:click="deleteLink({{ $link->id }})" class="text-red-500">Delete</button>
                        </td>
                    </tr>
                @endforeach
            </tbody>
            </table>
        @else
            <p class="text-center p-4">No links found..</p>
        @endif
      </div>
    </div>
  </div>
</div>

And the back-end:

<?php

namespace App\Http\Livewire;

use App\Models\Link;
use Livewire\Component;

class Analytics extends Component
{
    protected $listeners = ['linkCreated' => '$refresh'];

    public function deleteLink($id)
    {
        Link::find($id)->delete();
    }

    public function render()
    {
        return view('livewire.analytics', [
            'links' => auth()->user()->links()->latest()->get()
        ]);
    }
}

Then, include the components CreateLink and Analytics in dashboard.blade.php:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-center text-gray-800 leading-tight">
            {{ __('Welcome to URL Shortener') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            @livewire('create-link')
            @livewire('analytics')
        </div>
    </div>
</x-app-layout>

The final result should be like this:

reuslt

If you notice, I have updated the app logo, you can do so by editing the default Jetstream component application-mark:

<div class="flex items-center">
  <svg class="h-10" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m48.3612213 51.6400146c-7.3800049-7.3798828-19.3898926-7.3798828-26.7700195 0l-10.6298828 10.6298829c-7.380127 7.380127-7.380127 19.3901367 0 26.7700195 7.3798828 7.380127 19.3898926 7.380127 26.7698975 0l10.6300049-10.6298828c7.3800048-7.380127 7.3800048-19.3901367-.0000001-26.7700196zm-31.8100586 31.8100586c-4.289917-4.3000488-4.289917-11.2900391 0-15.5900879l10.630127-10.630127c4.2999268-4.2897949 11.2900391-4.2897949 15.5799561 0 4.2999268 4.3000488 4.2999268 11.2900391 0 15.5900879l-10.6199951 10.630127c-4.3000489 4.2900391-11.2900392 4.2900391-15.590088 0z" fill="#3a545f"/><path d="m89.0312653 10.960083c-7.3800049-7.380127-19.3900146-7.380127-26.7700195 0l-10.6199952 10.6298828c-7.3900146 7.380127-7.3900146 19.3898926 0 26.7700195 7.3800049 7.380127 19.3900146 7.380127 26.7700195 0l10.6199951-10.619873c7.3900147-7.3801269 7.3900147-19.3901367.0000001-26.7800293zm-31.8000489 31.8098145c-4.2999268-4.2998047-4.2999268-11.2900391 0-15.5898438l10.6300049-10.6201172c4.2900391-4.2998047 11.2800293-4.2998047 15.5800781 0 4.2999268 4.2900391 4.2999268 11.2900391 0 15.5800781l-10.630127 10.6298828c-4.2899169 4.3000489-11.2799071 4.3000489-15.579956.0000001z" fill="#3a545f"/><path d="m61.9912262 44.710083-17.289917 17.2800293c-1.8500977 1.8498535-4.8400879 1.8498535-6.6900635 0-1.8499756-1.8500977-1.8499756-4.8400879 0-6.6901855l17.2799073-17.289795c1.8500977-1.8400879 4.8500977-1.8400879 6.7000732 0 1.8399658 1.8498536 1.8399658 4.8498536 0 6.6999512z" fill="#ffd25a"/><g fill="#e6e7e8"><path d="m32.9172554 32.9235573c-.4984818.4984818-1.3056526.4980392-1.8036938 0l-6.5115223-6.5115242c-.4980412-.4980412-.4984818-1.305212 0-1.8036919.4984798-.4984818 1.3056507-.4980412 1.8036919 0l6.5115242 6.5115223c.4980392.4980412.4984818 1.305212 0 1.8036938z"/><path d="m40.3612823 29.5078201c-.696064.1116238-1.3497353-.3619156-1.4612617-1.0573654l-1.4581108-9.092514c-.1115265-.6954498.3612976-1.349638 1.0573654-1.4612617.696064-.1116238 1.3497353.3619156 1.4612617 1.0573654l1.4581108 9.092514c.1115266.6954498-.3612975 1.349638-1.0573654 1.4612617z"/><path d="m29.5015182 40.3675842c.1116238-.696064-.3619156-1.3497353-1.0573654-1.4612617l-9.092514-1.4581108c-.6954498-.1115265-1.349638.3612976-1.4612617 1.0573654-.1116238.696064.3619156 1.3497353 1.0573654 1.4612617l9.092514 1.4581108c.6954498.1115265 1.349638-.3612976 1.4612617-1.0573654z"/><path d="m67.077713 67.0840149c.4984818-.4984818 1.3056488-.4980469 1.803688 0l6.511528 6.5115204c.4980392.4980392.4984818 1.3052139 0 1.8036957s-1.3056564.4980392-1.8036957 0l-6.5115204-6.511528c-.4980468-.4980393-.4984816-1.3052064.0000001-1.8036881z"/><path d="m59.6336823 70.4997482c.696064-.1116257 1.3497353.3619156 1.4612617 1.0573654l1.4581108 9.092514c.1115265.6954498-.3612976 1.3496399-1.0573654 1.4612579-.696064.1116257-1.3497353-.361908-1.4612617-1.0573654l-1.4581108-9.0925064c-.1115266-.6954574.3612975-1.3496398 1.0573654-1.4612655z"/><path d="m70.4934464 59.6399841c-.1116257.696064.3619156 1.3497353 1.0573654 1.4612617l9.092514 1.4581108c.6954498.1115265 1.3496399-.3612976 1.4612579-1.0573654.1116257-.696064-.361908-1.3497353-1.0573654-1.4612617l-9.0925064-1.4581108c-.6954574-.1115265-1.3496399.3612976-1.4612655 1.0573654z"/></g></svg>
  <span class="mx-2">URL Shortener</span>
</div>

Step 3: Simulate users payments and upgrade plan

In this step, we'll not integrate any real payment gateway, however, we'll just have a button that will upgrade the user plan or cancel it. Of course, you'll not do that in real life 🀣

To start, let's build the UI for the UpgradeUserPlanForm component:

<x-jet-action-section>
    <x-slot name="title">
        {{ __('User plan') }}
    </x-slot>

    <x-slot name="description">
        {{ __('Upgrade your plan and enjoy pro features.') }}
    </x-slot>

    <x-slot name="content">
        <h3 class="text-lg font-medium text-gray-900">
            {{ __('Your current plan is') }} <strong>{{ ucfirst($currentPlan) }}</strong>
        </h3>

        <div class="mt-5">
            @if($currentPlan == 'free')
                <x-jet-button type="button" wire:click="upgradeToPro" wire:loading.attr="disabled">
                    {{ __('Upgrade to pro') }}
                </x-jet-button>
            @else
                <x-jet-danger-button type="button" wire:click="cancelPro" wire:loading.attr="disabled">
                    {{ __('Cancel pro version') }}
                </x-jet-danger-button>
            @endif            
        </div>
    </x-slot>
</x-jet-action-section>

And the back-end:

<?php

namespace App\Http\Livewire;

use Livewire\Component;

class UpgradeUserPlanForm extends Component
{
    public function upgradeToPro()
    {
        auth()->user()->update(['plan' => 'premium']);
    }

    public function cancelPro()
    {
        auth()->user()->update(['plan' => 'free']);
    }

    public function render()
    {
        return view('profile.upgrade-user-plan-form', [
            'currentPlan' => auth()->user()->plan
        ]);
    }
}

Then, add the UpgradeUserPlanForm component anywhere to the profile page profile/show.blade.php:

@livewire('upgrade-user-plan-form')

The UI of this component will look like this:

UpgradeUserPlanForm component

Then, let's use the new rate limit functionality that Laravel 8 provides:

Remember our requirements above: the free plan allows a user to have up to 10 hits/min, and the pro one is up to 100/min.

In your RouteServiceProvider, update the api rate limit to:

...
RateLimiter::for('api', function (Request $request) {
    // get the user plan from the link requested
    $plan = App\Models\Link::where('shortened', $request->link)->firstOrFail()->user->plan;

    // apply the logic here
    $limit = $plan == 'free' ? 10 : 100;

    return Limit::perMinute($limit);
});
..

Finally, add the following route to your web routes: (you might consider a dedicated controller for that but here is fine)

Route::get('/{link:shortened}', function(App\Models\Link $link) {
    $link->increment('views');

    return redirect($link->original);
})->middleware('throttle:api')->name("links.show");
url-shortener-layout.jpg

Of course, feel free to develop, extend, and improve the app features from this point. Have fun πŸ‘πŸ€“

You can also access the source code for the demo project on my Github account.