Jak v Laravelu vytvořit nástroj na přesměrování?


Autor Pavel Zaněk

14. 8. 2022

Obsah článku

Možná máte projekt, kde využíváte přesměrování – ať už jste přesměrování přidávali z důvodu změny URL adresy stránky nebo jste zkrátka potřebovali Vaše URL adresy upravit (např. změna logiky/struktury webu). Pokud se jedná o URL adresu veřejně dostupnou, možná jedním z důvodů byl Váš SEO specialista, který Vám poradil, že stránka při změně URL adresy ztratí hodnocení a udělá v rámci organiku „paseku“. Zkrátka s přesměrováním URL adres se programátor jednou za čas setká.

Co když však máte redakční systém, kde editoři píší články a jednou za čas udělají chybu v URL adrese? Přitom už článek může být zpublikovaný na sociálních sítí, rozeslán externím blogerům, apod. Tedy URL adresa obsahující chybu již je používaná, ale my se přesto rozhodneme URL adresu opravit a vytvořit nové přesměrování (z chybné URL adresy na opravenou).

V dnešním článku si zkusíme vytvoříme nástroj na přesměrování. Vytvoříme si tedy obyčejný CRUD (správa přesměrování) a data naimplementujeme do Laravelu (tedy pokud návštěvník navštíví URL adresu uvedenou v nástroji přesměrování, bude automaticky přesměrován na správnou URL adresu).

Na konci článku si celou aplikaci upravíme, resp. využijeme cache a vytvoříme si validaci, abychom neměli v databázi duplicitní záznamy.

Příprava aplikace

Pokud si chcete podobnou aplikaci vytvořit, začněte instalací Laravelu na Váš lokální stroj. Můžete tak udělat pomocí dříve napsaných článků:

Případně pouze sledujte zde uvedený kód. Všechny náležitosti by zde měly být uvedeny.

Migrace

Migraci můžeme vytvořit použitím artisan příkazu

sail artisan make:migration create_redirections_table

A následně tuto migraci upravíme:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('redirections', function (Blueprint $table) {
            $table->id();
            $table->string('old_url');
            $table->string('new_url');
            $table->boolean('is_permanent')->default(true);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('redirections');
    }
};

Model

Opět použijeme artisan pro vygenerování modelu.

sail artisan make:model Redirection

Model následně upravíme:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Redirection extends Model
{
    use HasFactory;

    protected $fillable = [
        'old_url',
        'new_url',
        'is_permanent',
    ];

    protected $casts = [
        'is_permanent' => 'boolean',
    ];
}

Kontroler

Ano, i kontroler i vygenerujeme pomocí artisan příkazu.

sail artisan make:controller RedirectionsController

A opět upravíme:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\Redirections\StoreRedirectionRequest;
use App\Http\Requests\Redirections\UpdateRedirectionRequest;
use App\Models\Redirection;

class RedirectionsController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $redirections = Redirection::all();
        return view('redirections.index', compact('redirections'));
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        return view('redirections.create');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(StoreRedirectionRequest $request)
    {
        Redirection::create($request->validated() + [
            'is_permanent' => isset($request->validated()['is_permanent']) && $request->validated()['is_permanent'] == '1' ? true : false,
        ]);
        return redirect()->route('redirections.index');
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  Redirection $redirection
     * @return \Illuminate\Http\Response
     */
    public function edit(Redirection $redirection)
    {
        return view('redirections.edit', compact('redirection'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  Redirection $redirection
     * @return \Illuminate\Http\Response
     */
    public function update(UpdateRedirectionRequest $request, Redirection $redirection)
    {
        $redirection->update($request->validated() + [
            'is_permanent' => isset($request->validated()['is_permanent']) && $request->validated()['is_permanent'] == '1' ? true : false,
        ]);
        return redirect()->route('redirections.index');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  Redirection $redirection
     * @return \Illuminate\Http\Response
     */
    public function destroy(Redirection $redirection)
    {
        $redirection->delete();
        return redirect()->route('redirections.index');
    }
}

Validace / validační pravidla/requesty

Pro větší bezpečnost si vygenerujeme validace.

sail artisan make:request Redirections/StoreRedirectionRequest
sail artisan make:request Redirections/UpdateRedirectionRequest

Do nich přidáme podmínky:

<?php

namespace App\Http\Requests\Redirections;

use Illuminate\Foundation\Http\FormRequest;

class StoreRedirectionRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'old_url' => 'required|url',
            'new_url' => 'required|url',
            'is_permanent' => 'sometimes'
        ];
    }
}
<?php

namespace App\Http\Requests\Redirections;

use Illuminate\Foundation\Http\FormRequest;

class UpdateRedirectionRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'old_url' => 'required|url',
            'new_url' => 'required|url',
            'is_permanent' => 'sometimes'
        ];
    }
}

Pohledy/Views

View bohužel nevygenerujeme pomocí artisan příkazu, alespoň ne ve výchozí instalaci Laravelu. Nevadí, vytvořme si jednodušší pohledy ručně:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
    <meta name="generator" content="Jekyll v3.8.5">
    <title>Starter Template · Bootstrap</title>
        
    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <link rel="canonical" href="https://getbootstrap.com/docs/4.3/examples/starter-template/">

    <!-- Bootstrap core CSS -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">


    <style>
      .bd-placeholder-img {
        font-size: 1.125rem;
        text-anchor: middle;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
      }

      @media (min-width: 768px) {
        .bd-placeholder-img-lg {
          font-size: 3.5rem;
        }
      }
    </style>
  </head>
  <body>
    <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
  <a class="navbar-brand" href="#">Navbar</a>
  <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
  </button>

  <div class="collapse navbar-collapse" id="navbarsExampleDefault">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item">
        <a class="nav-link" href="/">Home <span class="sr-only">(current)</span></a>
      </li>
      <li class="nav-item {{ request()->is('redirections*') ? 'active' : '' }}">
        <a class="nav-link" href="{{ route('redirections.index') }}">Redirects</a>
      </li>
    </ul>
  </div>
</nav>

<main role="main">

  <div class="starter-template mt-5 px-5 py-5">
    <h1>@yield('headline')</h1>
    @yield('content')
  </div>

</main><!-- /.container -->
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.7/dist/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
</body>
</html>
@extends('layouts.redirections')

@section('headline')
{{ __('Redirections') }}
@endsection

@section('content')
    <div class="container-fluid">
        <div class="fade-in">


            <div class="row">
                <div class="col-12">
                    <p>
                        <a href="{{ route('redirections.create') }}" class="btn btn-primary btn-sm">
                            {{ __('Create new redirect') }}
                        </a>
                    </p>
                </div>
            </div>

            @if (count($redirections) > 0)
                <div class="row">
                    <div class="col-12">
                        <div class="card">
                            <div class="card-body">
                                <table class="table table-responsive-sm">
                                    <thead>
                                        <tr>
                                            <th>{{ __('Old URL') }}</th>
                                            <th>{{ __('New URL') }}</th>
                                            <th>{{ __('Is permanent') }}</th>
                                            <th>{{ __('Action') }}</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        @foreach ($redirections as $redirect)
                                            <tr>
                                                <td>{{ $redirect->old_url }}</td>
                                                <td>{{ $redirect->new_url }}</td>
                                                <td>{{ $redirect->is_permanent ? __('Yes') : __('No') }}</td>
                                                <td>
                                                    <a href="{{ route('redirections.edit', $redirect) }}" class="btn btn-sm btn-secondary">
                                                        {{ __('Edit') }}
                                                    </a>
                                                    <form action="{{ route('redirections.destroy', $redirect) }}" method="POST" style="display: inline-block;">
                                                        @csrf
                                                        @method('DELETE')
                                                        <button class="btn btn-sm btn-danger" type="submit"
                                                            onclick="return confirm('{{ __('Are you sure?') }}')"> {{ __('Delete') }}</button>
                                                    </form>
                                                </td>
                                            </tr>
                                        @endforeach
                                    </tbody>
                                </table>
                            </div>
                        </div>
                    </div>
                </div>

            @else

                <div class="row">
                    <div class="col-12">
                        <p>
                            {{ __('There are no redirects.') }}
                        </p>
                    </div>
                </div>

            @endif

        </div>
    </div>
@endsection
@extends('layouts.redirections')

@section('headline')
{{ __('New redirect') }}
@endsection

@section('content')
    <div class="container-fluid">
        <div class="fade-in">

            <form action="{{ route('redirections.store') }}" method="POST">
                @csrf

                <div class="row">
                    <div class="col-12">

                        <div class="form-group">
                            <label for="old_url">{{ __('Old URL *') }}</label>
                            <input class="form-control" type="url" name="old_url" id="old_url" value="{{ old('old_url') }}">
                            @error('old_url')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                        <div class="form-group">
                            <label for="new_url">{{ __('New URL *') }}</label>
                            <input class="form-control" type="url" name="new_url" id="new_url" value="{{ old('new_url') }}">
                            @error('new_url')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                        <div class="form-group">
                            <div class="form-check checkbox">
                              <input class="form-check-input" id="is_permanent" type="checkbox" value="1" name="is_permanent" {{ old('is_permanent') ? 'checked' : '' }}>
                              <label class="form-check-label" for="is_permanent">{{ __('Is permanent') }}</label>
                            </div>
                            @error('is_permanent')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                    </div>

                    <div class="col-12 mt-3">
                        <button class="btn btn-sm btn-primary" type="submit"> {{ __('Save') }}</button>
                    </div>
                </div>
            </form>

        </div>
    </div>
@endsection
@extends('layouts.redirections')

@section('headline')
{{ __('Edit redirect') }}
@endsection

@section('content')
    <div class="container-fluid">
        <div class="fade-in">

            <form action="{{ route('redirections.update', $redirection) }}" method="POST">
                @csrf
                @method('PUT')

                <div class="row">
                    <div class="col-12">

                        <div class="form-group">
                            <label for="old_url">{{ __('Old URL *') }}</label>
                            <input class="form-control" type="url" name="old_url" id="old_url" value="{{ old('old_url') ?? $redirection->old_url }}">
                            @error('old_url')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                        <div class="form-group">
                            <label for="new_url">{{ __('New URL *') }}</label>
                            <input class="form-control" type="url" name="new_url" id="new_url" value="{{ old('new_url') ?? $redirection->new_url }}">
                            @error('new_url')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                        <div class="form-group">
                            <div class="form-check checkbox">
                              <input class="form-check-input" id="is_permanent" type="checkbox" value="1" name="is_permanent" {{ old('is_permanent') ?? $redirection->is_permanent ? 'checked' : '' }}>
                              <label class="form-check-label" for="is_permanent">{{ __('Is permanent') }}</label>
                            </div>
                            @error('is_permanent')
                                <div class="alert alert-danger mt-2">{{ $message }}</div>
                            @enderror
                        </div>

                    </div>

                    <div class="col-12 mt-3">
                        <button class="btn btn-sm btn-primary" type="submit"> {{ __('Save') }}</button>
                    </div>
                </div>
            </form>

        </div>
    </div>
@endsection

Router

...
Route::resource('redirections', \App\Http\Controllers\RedirectionsController::class)->except('show');
...

Základ aplikace

Takto jsme si vytvořili základ pro náš nový nástroj. Tedy můžeme spravovat přesměrování (přehled všech přesměrování, možnost vytvářet nové přesměrování, možnost upravovat již existující přesměrování a mazat vytvořená přesměrování).

Základ pro nástroj na přesměrování URL adres
Základ pro nástroj na přesměrování URL adres

Jelikož máme aplikaci připravenou, bude zapotřebí navázat data na Laravel. Tedy když návštěvník vstoupí na URL adresu uvedenou v našem nástroji na přesměrování (stará URL adresa), bude přesměrován na novou URL adresu.

Logika přesměrování

Celou logiku přesměrování bych osobně umístil do middleware, konkrétně do globálního middleware typu.

Middleware

Pojďme tedy vytvořit nový middleware. Můžete tak provést například pomocí příkazu:

sail artisan make:middleware RedirectionsMiddleware

Middleware si upravíme:

<?php

namespace App\Http\Middleware;

use App\Models\Redirection;
use Closure;
use Illuminate\Http\Request;

class RedirectionsMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        $redirectFromDb = Redirection::where('old_url', $request->url())->first();
        if($redirectFromDb){
            return redirect($redirectFromDb->new_url, $redirectFromDb->is_permanent ? 301 : 302);
        }
        return $next($request);
    }
}

Middleware následně přidáme do Kernelu.

/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array<int, class-string|string>
*/
protected $middleware = [
    // \App\Http\Middleware\TrustHosts::class,
    \App\Http\Middleware\TrustProxies::class,
    \Illuminate\Http\Middleware\HandleCors::class,
    \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
    \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
    \App\Http\Middleware\TrimStrings::class,
    \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    \App\Http\Middleware\RedirectionsMiddleware::class,
];

V tomto stavu nám už aplikace bude fungovat. Nicméně se nejedná o optimální řešení.

Aktualizace aplikace

Pojďme si znovu naši aplikaci projít a pokusit se pár věcí optimalizovat.

Přesměrování uložené v cache

Při každém requestu proběhne náš middleware, který se zeptá databáze, zda náhodou současná URL není uvedena v našem nástroji na přesměrování. To je ale nepříliš efektivní na server. Zkusme tuto část optimalizovat.

Když se zamyslím, ukládal bych do cache záznamy o přesměrování a tím se vyhnul dotazům přímo do databáze. Pokud máte nastavený Redis, vzhůru do kódu (samozřejmě můžete využít jiný driver pro cache, nicméně předpokládám, že jste si nainstalovali Laravel podobně jako já).

U middlewaru máme dvě možnosti – buď ukládat do cache všechny data o přesměrování (stará url, nová url a stavový kód, resp. zda se jedná o trvalé přesměrování), nebo budeme ukládat do cache pouze starou url adresu a pokud dojde ke shodě s aktuální url adresou, teprve pak se podíváme do databáze, abychom získali zbytek údajů o přesměrování.

V prvním případě se nemusíte ptát databáze vůbec, nicméně v cache bude daleko více údajů. V opačném případě budou v cache pouze potřebná data k určení, zda má proběhnout přesměrování a o zbytek dat se musíme posléze postarat.

Konec teorie, pojďme do praxe. Využiji druhý způsob – tedy v cache budou z nástroje pro přesměrování pouze hodnoty „stará url“. A v případě potřeby se zeptáme databáze na zbytek dat.

<?php

namespace App\Http\Middleware;

use App\Models\Redirection;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class RedirectionsMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        $oldUrls = Cache::rememberForever('redirects_old_urls', function () {
            return Redirection::select('old_url')->get();
        });

        $redirectFromCache = $oldUrls->where('old_url', $request->url())->first();
        if($redirectFromCache){
            $redirectFromDb = Redirection::where('old_url', $redirectFromCache->old_url)->first();
            return redirect(
                $redirectFromDb->new_url,
                $redirectFromDb->is_permanent ? 301 : 302
            );
        }

        return $next($request);
    }
}

Ano, přeci bude zapotřebí jeden dotaz do databáze – ale pouze do doby, dokud nebude cache na straně serveru smazána. Tedy aby se cache naplnila daty, provede se query do databáze. Cache se naplní daty a posléze už nikdy nedojde k opětovnému dotazu do databáze. Chytré že?

Co když ale v nástroji přibude nové přesměrování, případně někdo upraví již existující původní URL adresu, nebo dokonce přesměrování smaže (nejspíše v poslední řadě změna stavového kódu)? V tomto případě musíme aktualizovat cache.

Myšlenka je taková, že generovat cache můžeme nechat na middlewaru – tedy první návštěva/request nám naplní cache potřebnými daty. A to už máme, můžeme tedy přejít k mazání cache, pokud v nástroji bude nějaká změna (vytvoření/úprava/mazání).

K tomu můžeme využít observer. Jelikož však je zbytečné vytvářet pouze pro tuto logiku observer, využijeme spíše boot metodu v modelu. Upravíme tedy Redirection model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;

class Redirection extends Model
{
    use HasFactory;

    protected $fillable = [
        'old_url',
        'new_url',
        'is_permanent',
    ];

    protected $casts = [
        'is_permanent' => 'boolean',
    ];

    public static function boot()
    {
        parent::boot();

        static::created(function($redirection)
        {
            Cache::forget('redirects_old_urls');
        });

        static::updated(function($redirection)
        {
            Cache::forget('redirects_old_urls');
        });

        static::deleted(function($redirection)
        {
            Cache::forget('redirects_old_urls');
        });
    }
}

Ano, mohl jsem použít event „saving/saved“. Nicméně chci i tento kód optimalizovat. Když se zamyslíme v případě aktualizace přesměrování (úprava záznamu). Je zbytečné, aby se cache smazala v případě, pokud se změní nová url adresa nebo příznak, zda je přesměrování trvalé. Zkusme vymyslet logiku, aby se cache smazala pouze v případě, pokud se o daného záznamu změní stará url adresa – přeci jen v cache uchováváme pouze staré url adresy (resp. adresy, které je nutné přesměrovat, pokud na ně návštěvník vstoupí).

...
static::updated(function($redirection)
{
    if($redirection->isDirty('old_url')){
        Cache::forget('redirects_old_urls');
    }
});
...

Tedy pomocí této podmínky říkáme, aby se cache smazala pouze v případě, pokud se edituje sloupec old_url.

Unikátní stará URL adresa

Když se dále zamyslím, v databázi mohou být v rámci staré url adresy pouze unikátní záznamy. Tak se případně nestane, aby uživatel nástroje zadal 2x stejnou starou url adresu. Na straně middlewaru máme ošetřeno, že pracujeme pouze s prvním nalezeným záznamem z db, tudíž by aplikace nadále fungovala. Přesto se však jedná o chybu a uživatel by mohl být zmatený. Ošetříme tedy nástroj, aby umožňoval ukládat pouze unikátní staré url adresy.

Nejdříve pojďme upravit migraci.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('redirections', function (Blueprint $table) {
            $table->id();
            $table->string('old_url')->unique();
            $table->string('new_url');
            $table->boolean('is_permanent')->default(true);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('redirections');
    }
};

Dále upravíme validační requesty – tedy validaci pro přidávání a úpravu přesměrování.

<?php

namespace App\Http\Requests\Redirections;

use Illuminate\Foundation\Http\FormRequest;

class StoreRedirectionRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'old_url' => 'required|url|unique:redirections,old_url',
            'new_url' => 'required|url',
            'is_permanent' => 'sometimes'
        ];
    }
}
<?php

namespace App\Http\Requests\Redirections;

use Illuminate\Foundation\Http\FormRequest;

class StoreRedirectionRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'old_url' => 'required|url|unique:redirections,old_url',
            'new_url' => 'required|url',
            'is_permanent' => 'sometimes'
        ];
    }
}

U vytváření říkáme, že se musí jednat o unikátní záznam. Při ukládání již existujícího záznamu se opět ptáme, aby stará url adresa nebyla duplicitní, nicméně vynecháme aktuálně upravovaný záznam.

Pokud by se uživatel rozhodl vytvořit záznam, který by byl duplicitní v rámci starých url adres, bude informován.

Možnost přidávání pouze unikátních záznamů
Možnost přidávání pouze unikátních záznamů

Tip na závěr – Data o používanosti přesměrování

Když už jsme v tomto stavu, bylo by dobré udělat logiku dynamičtější a profesionálnější.

Dejme tomu, že by nás zajímalo, kdy bylo přesměrování naposledy použito. Nebylo by také špatné zaznamenávat přesměrování v průběhu času. Z toho bychom případně mohli vytvořit i graf a mít vizuální představu o tom, jak lidé navštěvují URL adresy vracející přesměrování.

Tyto údaje by nám také posloužily k tomu, abychom mohli mazat již nepoužívaná přesměrování – tím si uděláme nejen místo v databázi, ale také se budou rychleji dohledávat přesměrování, takže nějaký ten performance. Navíc tuto logiku můžeme nasadit na cron a celý proces bude automatizovaný.

Když už budeme tak trochu optimalizovat. Máme zde 2 zbytečné údaje – časová známka vytvoření přesměrování a časová známka poslední úpravy přesměrování. Někdo tyto údaje využije, my však ne, proto jej v této iteraci nad kódem odebereme.

Ačkoliv budeme optimalizovat, je dobré si uvědomit, že s těmito údaji vzniknou nové dotazy do databáze (tedy uložení dat o používanosti přesměrování). I tak se budeme snažit tuto část co nejvíce optimalizovat.

Migrace

Pojďme nejdříve upravit migraci se záznamy o přesměrování.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('redirections', function (Blueprint $table) {
            $table->id();
            $table->string('old_url')->unique();
            $table->string('new_url');
            $table->boolean('is_permanent')->default(true);
            $table->dateTime('last_used')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('redirections');
    }
};

Nyní vytvoříme novou migraci obsahující data o přesměrování. Tabulku si například pojmenujeme redirections_data (ztratíme jisté konvence, ale naučíme se jak správně vyřešit). Migraci opět můžeme vytvořit artisan příkazem.

sail artisan make:migration create_redirections_data_table

Do migrace přidáme:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('redirections_data', function (Blueprint $table) {
            $table->id();
            $table->foreignId('redirection_id')->references('id')->on('redirections')->onDelete('cascade');
            $table->dateTime('used_at');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('redirections_data');
    }
};

Můžete si všimnout, že u druhé migrace jsme smazali tuto část (výchozí, pokud generujete migrace pomocí artisan příkazů)

...
$table->timestamps();
...

Nesmíme zapomenout, že tuto změnu musíme definovat v modelu.

Model

Nejdříve upravme již existující model na přesměrování.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;

class Redirection extends Model
{
    use HasFactory;

    protected $fillable = [
        'old_url',
        'new_url',
        'is_permanent',
        'last_used',
    ];

    protected $casts = [
        'is_permanent' => 'boolean',
        'last_used' => 'datetime',
    ];

    public static function boot()
    {
        parent::boot();

        static::created(function($redirection)
        {
            Cache::forget('redirects_old_urls');
        });

        static::updated(function($redirection)
        {
            if($redirection->isDirty('old_url')){
                Cache::forget('redirects_old_urls');
            }
        });

        static::deleted(function($redirection)
        {
            Cache::forget('redirects_old_urls');
        });
    }

    public function redirectionData()
    {
        return $this->hasMany(RedirectionData::class);
    }
}

Uvnitř třídy jsme přidali nový vztah (hasMany) na data. Dále jsme přidali info, aby Laravel přistupoval k last_used jako k časové známce.

Nyní vytvoříme nový model právě pro data o přesměrování. Opět pomocí artisanu.

sail artisan make:model RedirectionData

Model si upravíme:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class RedirectionData extends Model
{
    use HasFactory;

    protected $table = 'redirections_data';

    public $timestamps = false;

    protected $fillable = [
        'redirection_id',
        'used_at',
    ];

    protected $casts = [
        'used_at' => 'datetime',
    ];

    public function redirection()
    {
        return $this->belongsTo(Redirection::class);
    }
}

V tomto modelu jsme si díky ztrátě konvence museli definovat název tabulky v databázi pomocí proměnné $table. Dále zde máme proměnnou na vypnutí časových známek. Hodnoty ve sloupci used_at budeme mít uloženy jako datetime – můžeme tak říct opět Laravelu, ať s hodnotami z databáze pracuje jako s datem/časem, zkrátka jako s časovou známkou. Závěrem jsme si definovali obrácený vztah (belongsTo) – tedy k jakému přesměrování data patří.

Middleware

V rámci middlewaru budeme ukládat info o posledním použití přesměrování.

<?php

namespace App\Http\Middleware;

use App\Models\Redirection;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class RedirectionsMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next)
    {
        $oldUrls = Cache::rememberForever('redirects_old_urls', function () {
            return Redirection::select('old_url')->get();
        });

        $redirectFromCache = $oldUrls->where('old_url', $request->url())->first();
        if($redirectFromCache){
            $redirectFromDb = Redirection::where('old_url', $redirectFromCache->old_url)->first();
            $redirectFromDb->update([
                'last_used' => now()
            ]);
            return redirect(
                $redirectFromDb->new_url,
                $redirectFromDb->is_permanent ? 301 : 302
            );
        }

        return $next($request);
    }
}

Už v tomto kroku můžeme ukládat i data o přesměrování. Přeci jen však dbáme celou dobu na optimalizaci.

Máme několik možností, jak ukládat data o přesměrování:

  1. Vytvořit data přímo v middlewaru
  2. Vytvořit data v boot metodě modelu
  3. Vytvořit event, na který bude zavěšen listener, který se následně postará o vytvoření dat pro přesměrování

Jelikož jsem zastáncem programování v rámci eventů, využijeme 3. metodu – ačkoliv by předchozí dvě byly daleko jednodušší.

Event

sail artisan make:event RedirectionWasUsedEvent
<?php

namespace App\Events;

use App\Models\Redirection;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class RedirectionWasUsedEvent
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * The redirection instance.
     *
     * @var \App\Models\Redirection
     */
    public $redirection;
 
    /**
     * Create a new event instance.
     *
     * @param  \App\Models\Redirection  $redirection
     * @return void
     */
    public function __construct(Redirection $redirection)
    {
        $this->redirection = $redirection;
    }
}

Listener

sail artisan make:listener CreateRedirectionDataListener
<?php

namespace App\Listeners;

use App\Events\RedirectionWasUsedEvent;
use Illuminate\Contracts\Queue\ShouldQueue;

class CreateRedirectionDataListener implements ShouldQueue
{
    /**
     * Handle the event.
     *
     * @param  \App\Events\RedirectionWasUsedEvent  $event
     * @return void
     */
    public function handle(RedirectionWasUsedEvent $event)
    {
        $event->redirection->redirectionData()->create([
            'used_at' => now()
        ]);
    }
}

Registrace a spuštění eventu

Nyní náš nový event a listener zaregistrujeme. To provedeme v souboru EventServiceProvider.

use App\Events\RedirectionWasUsedEvent;
use App\Listeners\CreateRedirectionDataListener;
...

    /**
     * The event to listener mappings for the application.
     *
     * @var array<class-string, array<int, class-string>>
     */
    protected $listen = [
        ...
        RedirectionWasUsedEvent::class => [
            CreateRedirectionDataListener::class,
        ],
        ...
    ];
...

Dále tento event budeme spouštět v případě, pokud se u přesměrování změní údaj o posledním použití. Spouštění přidáme do našeho modelu s přesměrováním, konkrétně aktualizujeme boot metodu.

use App\Events\RedirectionWasUsedEvent;
...
static::updated(function($redirection)
{
    if($redirection->isDirty('old_url')){
        Cache::forget('redirects_old_urls');
    }
    if($redirection->isDirty('last_used')){
        RedirectionWasUsedEvent::dispatch($redirection);
    }
});
...

Příkaz na mazání nepoužívaných přesměrování

Bylo by dobré, kdyby aplikace sama od sebe mazala:

  1. přesměrování, která byla vytvořena před více jak půl rokem a nebyla ani jednou použita
  2. přesměrování s poledním datem použití před půl rokem

Můžeme vytvořit příkaz/command, který se pro tento příklad bude spouštět každý den v 8 hodin ráno.

sail artisan make:command PruneRedirectionsTableCommand
<?php

namespace App\Console\Commands;

use App\Models\Redirection;
use Carbon\Carbon;
use Illuminate\Console\Command;

class PruneRedirectionsTableCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'redirections:prune';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Prune redirections table - old unused records.';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $this->comment('Removing redirections...');
        Redirection::whereNull('last_used')->where('created_at', '<', Carbon::now()->subMonth(6))->delete();
        Redirection::where('last_used', '<', Carbon::now()->subMonth(6))->delete();
        $this->info("Redirections Table was pruned.");
        return 0;
    }
}

Dále tento příkaz přidáme do Kernelu.

<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * Define the application's command schedule.
     *
     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        $schedule->command('redirections:prune')->dailyAt('8:00');
    }

    /**
     * Register the commands for the application.
     *
     * @return void
     */
    protected function commands()
    {
        $this->load(__DIR__.'/Commands');

        require base_path('routes/console.php');
    }
}

A máme hotovo 🙂 Příkaz v produkčním prostředí by samozřejmě stačilo spouštět v delším časovém horizontu, například jednou za týden.

Závěr

Pomocí Laravelu jsme si pohodlně vytvořili jednoduchou aplikaci na přesměrování, kterou nadále můžeme rozšiřovat. Navíc jsme využili cache pro lepší výkon a také jsme přidali logiku, která nám pomůže optimalizovat databázi.

Jak jste si mohli všimnout, na všechno kromě šablon jsme využili sílu artisan příkazů, které nám vygenerovaly soubory včetně částí kódu. Akorát jsme doplňovali potřebnou logiku.

Budu velmi rád za zpětnou reakci, za Vaše tipy a názory.

Užitečné zdroje

Vyzkoušejte si

Pár detailů v aplikaci jsem záměrně vypustil. Můžete si tak zkusit aplikaci dokončit. Níže uvedu body, na které bych se zaměřil:

  • validace pro boolean typ + lepší způsob ukládání boolean hodnot (Store/Update Request)
  • zobrazovat ve výpise přesměrování datum posledního použití přesměrování
  • zobrazovat ve výpise přesměrování počet použití přesměrování
  • vytvořit v nástroji detail přesměrování a zobrazovat základní údaje o přesměrování
  • přidat do detailu přesměrování graf zobrazující použití přesměrování v průběhu času
  • zlepšit UX aplikace (například úprava šablony, přidání tlačítka zpět na výpis z editace přesměrování, apod.)
  • znemožnit úpravu staré url adresy
  • vytvořit seeder s testovacími daty
  • vytvořit testy pro aplikaci
  • import a export přesměrování
  • do výpisu přesměrování přidat stránkování
  • do výpisu přesměrování přidat vyhledávání
  • ukládat další data o přesměrování (např. referer)
  • přidat flash zprávy / toasts / popupy
  • převést aplikaci na balíček instalovatelný pomocí composeru 🙂

Další články

Autor Pavel Zaněk

Tvořím webové stránky s velkou oblibou v online marketingu - především v odvětví optimalizace pro vyhledávače (SEO). Především se specializuji na tvorbu webových aplikací na míru (PHP framework Laravel s využitím dalších moderních technologií) a na tvorbu webových stránek s použitím redakčního systému Wordpress. Ačkoliv se více zaměřuji na logiku webových aplikací (back end), jsem schopný tvořit i moderní a responzivní šablony v rámci webové grafiky (front end).

14. 8. 2022