Laravel Boilerplate

Lorsqu’on utilise fréquemment Laravel on est amené à effectuer des tâches répétitives et à utiliser une certain nombre de classes et fonctionnalités à travers différents projets. On pourrait ainsi imaginer une trame de base comportant tout ce qu’on utilise habituellement. C’est en gros ce qui est réalisé par Laravel Boilerplate. Voyons un peu ce qui se cache dans cette librairie qui a obtenu quand même plus de 2600 stars sur Github…

Installation

On dispose d’un site plutôt bien fait :

Il y a une page de démarrage rapide (Quick Start). Là il y a la liste des fonctionnalités, des copies d’écran, des explications pour le téléchargement et l’installation.

Comme c’est juste un Laravel amélioré l’installation est assez classique. On va commencer par récupérer le dépôt :

git clone https://github.com/rappasoft/laravel-5-boilerplate.git boilerplate

On trouve un fichier .env.example plutôt bien garni par rapport à celui de base de Laravel, il faut le renommer en .env.

Ensuite il n’y a plus qu’à lancer l’installation :

cd boilerplate
composer install

Ça dure un moment parce qu’il y a pas mal de package prévus…

Il faut ensuite générer une clé pour le cryptage :

php artisan key:generate

Ensuite on crée un base de données et on renseigne .env :

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=boilerplate
DB_USERNAME=root
DB_PASSWORD=

On peut alors lancer les migrations et la population :

php artisan migrate --seed

On se retrouve avec 12 tables :

Avec 3 utilisateurs par défaut :

Pour le frontend on a le choix entre npm et yarn. Personnellement j’utilise npm, donc il faut installer :

npm install

Les 1432 packages mettent un petit moment à s’installer…

On trouve aussi de nombreux tests qu’on peut lancer avec phpUnit :

Il faut prévoir l’extension pdo_sqlite de PHP et également renseigner la configuration des mails pour éviter de tomber sur une erreur.

État des lieux

Tous semble correct alors je lance :

Une page d’accueil sommaire avec une barre de navigation, un accès au login et à l’enregistrement, un large choix de langues, une page de contact avec un formulaire et la barre de débogage.

On peut accéder à l’administration avec :

Username: admin@admin.com

Password: 1234

Le template d’administration est CoreUI dans sa version gratuite, c’est à dire pratiquement sans plugins. Personnellement je préfère AdminLTE.

On trouve une gestion des utilisateurs :

Une gestion des rôles :

Une visualisation des logs :

Pour en apprendre plus il faut plonger dans la documentation ou fouiller un peu le code…

Les contrôleurs

On trouve de nombreux contrôleur rangés dans leur dossier et sous-dossiers :

Les validations sont systématiquement réalisées par des Form Request et la gestion des données au travers de repositories. Du coup le code est propre :

/**
 * @param ManageUserRequest $request
 *
 * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
 */
public function index(ManageUserRequest $request)
{
    return view('backend.auth.user.index')
        ->withUsers($this->userRepository->getActivePaginated(25, 'id', 'asc'));
}

Les middlewares

On trouve ces middlewares :

On remarque l’ajout de :

  • LocaleMiddleware pour la gestion des locales associé à la configuration config/locale.php
  • PasswordExpires si on veut une expiration du mot de passe au bout d’un certain délai

D’autre part on a aussi les middlewares du package spatie/laravel-permission pour la gestion des rôles :

  • RoleMiddleware 
  • PermissionMiddleware

L’ajout de ces middlewares permet de protéger facilement les routes :

Route::group([
    'middleware' => 'role:administrator',
], function () {

Les modèles

Les modèles se trouvent dans le dossier app/Models :

On trouve de nombreux traits. Ils servent pour gérer les attributs, les relations, les scopes et les méthodes spécifiques. Ça fait au final pas mal de fichiers. Par exemple pour le modèle User on trouve une rafale de traits :

class User extends Authenticatable
{
    use HasRoles,
        Notifiable,
        SendUserPasswordReset,
        SoftDeletes,
        UserAttribute,
        UserMethod,
        UserRelationship,
        UserScope,
        Uuid;

C’est un choix architectural…

Les routes

Là aussi on trouve de nombreux fichiers :

Pour comprendre l’organisation il faut regarder le code dans routes/web.php :

/*
 * Frontend Routes
 * Namespaces indicate folder structure
 */
Route::group(['namespace' => 'Frontend', 'as' => 'frontend.'], function () {
    include_route_files(__DIR__.'/frontend/');
});

L’espace de nom est analogue au chemin des dossiers. Ici on va dans le dossier frontend. Dans ce dossier dans le fichier auth.php on trouve par exemple :

Route::group(['namespace' => 'Auth', 'as' => 'auth.'], function () {

Du coup les routes seront préfixées par frontend.auth :

Il faut juste un peu s’habituer au système…

Les providers

On trouve ces providers :

On a donc l’ajout de :

  • BladeServiceProvider pour ajouter des directives Blade
  • ComposerServiceProvider pour ajouter des composeurs de vues

Il y a quelques réglages dans AppServiceProvider : la locale, le forçage éventuel en HTTPS, les templates pour Bootstrap 4…

Les assets

Les assets sont fournis :

On peut mieux comprendre l’organisation en allant jeter un œil dans webpack.mix.js :

mix.sass('resources/assets/sass/frontend/app.scss', 'public/css/frontend.css')
    .sass('resources/assets/sass/backend/app.scss', 'public/css/backend.css')
    .js('resources/assets/js/frontend/app.js', 'public/js/frontend.js')
    .js([
        'resources/assets/js/backend/before.js',
        'resources/assets/js/backend/app.js',
        'resources/assets/js/backend/after.js'
    ], 'public/js/backend.js');

if (mix.inProduction() || process.env.npm_lifecycle_event !== 'hot') {
    mix.version();
}

Conclusion

Ce boilerplate est bien structuré et codé, c’est une bonne base de départ pour un projet à condition d’accepter l’organisation imposée. Il peut faire gagner du temps au niveau de la constitution du backend. Il est équipé d’une batterie complète de tests. Je regrette juste le choix de CoreUI.




Créer une application avec Laravel 5.5 – Ajouter le changement de catégorie

Dans les commentaires pour cette application j’ai eu la demande de pouvoir changer la catégorie des photos. Comme c’est quelque chose qui touche à pas mal de points et qui peut s’avérer didactique je détaille dans cet article tout le processus.

Le visuel

On ne doit permettre la modification de la catégorie d’une photo que pour le propriétaire de la photo et pour l’administrateur. Pour le moment on dispose juste d’une icône pour la suppression de la photo :

On va ajouter une nouvelle icône pour le changement de la catégorie :

Ça se passe dans la vue home :

@adminOrOwner($image->user_id)
    <a class="category-edit" id="{{$image->category_id}}" href="#" data-toggle="tooltip" title="@lang('Changer de catégorie')"><i class="fa fa-edit"></i></a>
    <a class="form-delete" href="{{ route('image.destroy', $image->id) }}" data-toggle="tooltip" title="@lang('Supprimer cette photo')"><i class="fa fa-trash"></i></a>
    ...
@endadminOrOwner

Il faut à présent décider comment on va présenter les choses, il faut pouvoir sélectionner une catégorie dans une liste.

Il me semble que le plus simple est d’utiliser une feuille modale, d’autant qu’on dispose de Bootstrap pour le faire.

Toujours dans ma vue home j’ajoute le code pour la feuille modale :

<div class="modal fade" id="changeCategory" tabindex="-1" role="dialog" aria-labelledby="categoryLabel" aria-hidden="true">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title" id="categoryLabel">@lang('Changement de la catégorie')</h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form action="" method="POST">
                    <div class="form-group">
                        <select class="form-control" name="category_id">
                        @foreach($categories as $cat)
                            <option value="{{ $cat->id }}">{{ $cat->name }}</option>
                        @endforeach
                        </select>
                    </div>
                    <button type="submit" class="btn btn-primary">@lang('Envoyer')</button>
                </form>
            </div>
        </div>
    </div>
</div>

Et le déclencheur dans le Javascript :

$('.category-edit').click(function (e) {
    e.preventDefault()
    $('#changeCategory').modal('show')
})

En cliquant sur l’icône j’ouvre bien la feuille modale et je dispose de la liste des catégories :

J’ai juste un petit souci : je n’ai pas la catégorie actuelle sélectionnée. Comment faire ?

On va régler ça en ajoutant un petite ligne de script :

$('.category-edit').click(function (e) {
    e.preventDefault()
    $('select').val($(this).attr('id'))
    $('#changeCategory').modal('show')
})

Et maintenant tout va bien ! Passons maintenant à la partie serveur…

Routes et contrôleur

Routes

Au niveau des routes on en a besoin d’une seule, on va ajouter update :

Route::middleware('auth')->group(function () {

    ...

    Route::resource('image', 'ImageController', [
        'only' => ['create', 'store', 'destroy', 'update']
    ]);

})

Contrôleur

Dans le contrôleur ImageController on ajoute la méthode update :

/**
 * Update the specified resource in storage.
 *
 * @param \Illuminate\Http\Request $request
 * @param \App\Models\Image
 * @return \Illuminate\Http\Response
 */
public function update(Request $request, Image $image)
{
 $image->category_id = $request->category_id;
 $image->save();

 return redirect()->back();
}

Le formulaire

Il nous faut revenir à notre formulaire pour le compléter :

<form action="" method="POST">
    <input type="hidden" name="_method" value="PUT">
    <input type="hidden" name="_token" value="{{ csrf_token() }}">

J’ai laissé l’action vierge parce qu’au départ on ne sait pas quelle image va être concernée. Il va donc falloir renseigner cette information en ajoutant un peu de Javascript :

$('a.category-edit').click(function (e) {
    e.preventDefault()
    $('select').val($(this).attr('id'))
    $('form').attr('action', $(this).next().attr('href'))
    $('#changeCategory').modal('show')
})

Pour l’url l’astuce est d’aller récupérer celle prévue dans le formulaire de suppression parce qu’elle est exactement celle dont on a besoin !

Et là ça doit fonctionner !

Un peu de sécurité

On ne veut pas que n’importe qui change la catégorie et un petit malin pourrait encore y parvenir là.

On va ajouter changer la méthode delete dans ImagePolicy pour la nommer manage (parce qu’elle ne va plus servir seulement à la suppression) :

public function manage(User $user, Image $image)
{
    return $user->id === $image->user_id;
}

On va actualiser la méthode destroy de ImageController :

public function destroy(Image $image)
{
    $this->authorize('manage', $image);

    $image->delete();

    return back();
}

Et ajouter l’autorisation dans update :

public function update(Request $request, Image $image)
{
    $this->authorize('manage', $image);
    
    $image->category_id = $request->category_id;
    $image->save();

    return redirect()->back();
}

Maintenant on est tranquilles !

Un alerte

On pourrait un peu améliorer en ajoutant une alerte pour prévenir l’utilisateur que le changement a bien eu lieu.

Dans la vue home on ajoute l’alerte :

<main class="container-fluid">
    @if(session('updated'))
        <div class="alert alert-dark" role="alert">
            {{ session('updated') }}
        </div>
    @endif

Et dans la méthode update du contrôleur on flashe la session :

return redirect()->back()->with('updated', __('La catégorie a bien été changée !'));

Et maintenant au changement on a :

Les langues

Comme l’application est prévue aussi en anglais il faut ajouter les textes. On va utiliser mon package des langues qui est normalement prévu avec l’application :

On voit qu’il nous manque 3 textes. On va les ajouter :

Il n’y a plus qu’à les entrer dans les emplacements prévus dans en.json :

{
    ...
    "Changement de la catégorie": "Category update",
    "Changer de catégorie": "Update category",
    ...
   
    "La catégorie a bien été changée !": "Category updated successfully !",
    ...
}

Et après vérifiez que vous avez bien travaillé :

Conclusion

On voit que pour ajouter une fonctionnalité il faut bien prendre en compte tous les éléments et être méthodiques. Ce n’est évidemment pas la seule façon d’arriver au résultat mais ça me paraît faire partie des plus simples et rapides.




Créer une application avec Laravel 5.5 – Les tests

Pour être honnête je n’aime pas coder des tests, j’ai souvent l’impression de perdre mon temps. Mais je reconnais leur grande utilité. Dans l’idéal il faudrait commencer par eux (Test-driven development) ou au moins établir les tests au fur et à mesure. Le grand intérêt des tests à mes yeux est de s’assurer qu’on a pas tout cassé d’un côté en bricolant d’un autre côté. Dans ce chapitre on va mettre en place des tests pour la galerie.

Il y a deux grandes catégories de tests dans Laravel : Http (PHPUnit) et Navigateur (Dusk). Dans ce chapitre on ne s’intéressera qu’à la première catégorie.

On se prépare

Laravel est pensé pour intégrer facilement des tests et il comporte de base PHPUnit avec un fichier phpunit.xml à la racine :

<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>

        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">./app</directory>
        </whitelist>
    </filter>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
    </php>
</phpunit>

On trouve aussi un dossier spécifique déjà un peu garni :

On va supprimer les deux exemples qui ne nous serviront pas.

Le principal souci lors de test réside dans la base de données. On va régler ça en utilisant sqlite en mémoire :

<php>
    ...
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

On va s’arranger pour recréer la base avant chaque test, et tant qu’à faire on va la remplir de données.

Toutes les classes de test héritent de TestCase.php :

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
}

Cette classe n’est pas très garnie à la base mais c’est le lieux idéal pour préparer les tests.

On va utiliser le trait RefreshDatabase qui est destiné à régénérer la base. Si on regarde la fonction qui effectue la régénération d ela base en mémoire on trouve :

protected function refreshInMemoryDatabase()
{
    $this->artisan('migrate');

    $this->app[Kernel::class]->setArtisan(null);
}

Donc une simple migration. Pour la galerie il nous faut plus que ça :

  • une population (seed)
  • une chargement des catégories qui sont partagées par défaut par toutes les vues.

On va donc créer un autre trait pour surcharger cette fonction :

Avec ce code :

<?php

namespace Tests;

use Illuminate\Contracts\Console\Kernel;
use App\Models\Category;

trait Init
{
    /**
     * Refresh the in-memory database.
     *
     * @return void
     */
    protected function refreshInMemoryDatabase()
    {
        $this->artisan('migrate');

        $this->artisan('db:seed');

        view ()->share ('categories', Category::all ()); 

        $this->app[Kernel::class]->setArtisan(null);
    }
}

Et on met à jour TestCase.php :

<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, RefreshDatabase, Init {
        Init::refreshInMemoryDatabase insteadof RefreshDatabase;
    }

    /**
     * Authentification.
     *
     * @return void
     */
    protected function auth($id) 
    {
        $user = User::find($id);

        $this->actingAs($user);
    }
}

J’ai ajouté une fonction qui (auth) nous permettra d’authentifier un utilisateur pour les tests.

Pour terminer ces préparatifs je signale que personnellement je mets le fichier phar de PHPUnit à la racine pour me simplifier la vie et la syntaxe des commandes…

Les catégories

On va maintenant mettre en place des tests pour les catégories : création, modification, suppression et tant qu’à faire les validations qui vont avec.

php artisan make:test CategoryTest

On a ce code par défaut :

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class CategoryTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testExample()
    {
        $this->assertTrue(true);
    }
}

On peut supprimer la référence au trait RefreshDatabase qu’on a déjà mis dans TestCase.php.

Création d’une catégorie

Réussite

On ajoute ce code pour tester la création d’une catégorie :

public function testAddCategory()
{
    $this->auth(1);

    $this->get('/category/create')
        ->assertSee('Name');

    $response = $this->post('/category', [
        'name' => 'Une catégorie',
    ]);

    $this->assertDatabaseHas('categories', [
        'name' => 'Une catégorie',
        'slug' => 'une-categorie',
    ]);

    $response->assertStatus(302)
        ->assertHeader('Location', url('/'));
}

On a vérifié (on commence par authentifier l’administrateur) :

  • qu’on peut bien afficher le formulaire
  • qu’on peut soumettre le formulaire
  • que la base est bien mise à jour
  • que la réponse est correcte

Échec de validation

On va aussi tester la validation :

public function testAddCategoryFail()
{
    $this->auth(1);

    // Required
    $response = $this->post('/category');
    $response->assertSessionHasErrors('name');

    // Unique
    $response = $this->post('/category', [
        'name' => 'Maisons',
    ]);
    $response->assertSessionHasErrors('name');

    // Max length
    $response = $this->post('/category', [
        'name' => str_random(256),
    ]);
    $response->assertSessionHasErrors('name');

    // String
    $response = $this->post('/category', [
        'name' => 256,
    ]);
    $response->assertSessionHasErrors('name');
}

On a vérifié les règles :

  • required
  • unique
  • max
  • string

Modification d’une catégorie

Réussite

On va tester de la même manière la modification d’une catégorie :

public function testUpdateCategory()
{
    $this->auth(1);

    $this->get('/category/2/edit')
        ->assertSee('Maisons');

    $response = $this->put('/category/2', [
        'name' => 'Immeubles',
    ]);

    $this->assertDatabaseHas('categories', [
        'name' => 'Immeubles',
    ]);

    $this->assertDatabaseMissing('categories', [
        'name' => 'Maisons',
    ]);

    $response->assertStatus(302)
        ->assertHeader('Location', url('/'));
}

On a vérifié  :

  • qu’on peut bien afficher le formulaire
  • qu’on peut soumettre le formulaire
  • que la base est bien mise à jour
  • que la réponse est correcte

Échec de validation

On va aussi tester la validation :

public function testUpdateCategoryFail()
{
    $this->auth(1);

    // Required
    $response = $this->put('/category/2');
    $response->assertSessionHasErrors('name');

    // Unique
    $response = $this->put('/category/2', [
        'name' => 'Animaux',
    ]);
    $response->assertSessionHasErrors('name');

    // Max length
    $response = $this->put('/category/2', [
        'name' => str_random(256),
    ]);
    $response->assertSessionHasErrors('name');

    // String
    $response = $this->put('/category/2', [
        'name' => 256,
    ]);
    $response->assertSessionHasErrors('name');
}

On a vérifié les règles :

  • required
  • unique
  • max
  • string

Suppression d’une catégorie

On va vérifier qu’on peut supprimer une catégorie :

public function testDeleteCategory()
{
    $this->auth(1);

    $response = $this->delete('/category/1');

    $this->assertDatabaseMissing('categories', [
        'name' => 'Paysages',
    ]);

    $response->assertStatus(200);
}

On a vérifié :

  • qu’on peut soumettre le formulaire
  • qu’on met la base à jour
  • qu’on retourne une réponse correcte

Les images

On va maintenant mettre en place des tests pour les images : création, suppression et tant qu’à faire les validations qui vont avec.

php artisan make:test ImageTest

Ajout d’une image

Réussite

Pour l’ajout d’une image on va devoir :

  • simuler (fake) le storage
  • simuler (fake) le téléchargement

Voici le test :

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

class ImageTest extends TestCase
{
    /**
     * Test add image.
     *
     * @return void
     */
    public function testAddImage()
    {
        $this->auth(2);

        $this->get('/')
            ->assertSee('Add an image');

        $this->get('/image/create')
            ->assertSee('Description');

        Storage::fake('images');
        Storage::fake('thumbs');

        $response = $this->post('/image', [
            'image' => UploadedFile::fake()->image('paysage.jpg'),
            'category_id' => 2,
            'description' => 'un beau paysage',
        ]);

        $this->assertDatabaseHas('images', [
            'description' => 'un beau paysage',
        ]);

        $response->assertStatus(302)
            ->assertHeader('Location', url('/image/create'));
    }
}

On a vérifié :

  • qu’on a l’item dans le menu
  • qu’on affiche le formulaire
  • qu’on peut soumettre le formulaire
  • qu’on met bien la base à jour
  • qu’on renvoie une réponse correcte

Échec de validation

On va aussi tester la validation :

public function testImageFail()
{
    $this->auth(2);

    // Required
    $response = $this->post('/image');
    $response->assertSessionHasErrors(['image', 'category_id']);

    // Image
    $response = $this->post('/image', [
        'image' => 'texte',
    ]);
    $response->assertSessionHasErrors('image');

    // Max
    $response = $this->post('/image', [
        'image' => UploadedFile::fake()->image('paysage.jpg')->size(2001),
        'description' => str_random(256),
    ]);
    $response->assertSessionHasErrors(['image', 'description']);

    // Exists
    $response = $this->post('/image', [
        'category_id' => 10,
    ]);
    $response->assertSessionHasErrors('category_id');
}

On a vérifié les règles :

  • required
  • unique
  • max
  • exists

Suppression d’une image

On va vérifier qu’on peut supprimer une image :

public function testDeleteImage()
{
    $this->auth(1);

    $response = $this->delete('/image/20');

    $this->assertDatabaseMissing('images', [
        'id' => 20,
    ]);

    $response->assertStatus(302)
        ->assertHeader('Location', url('/'));
}

On a vérifié :

  • qu’on peut soumettre le formulaire
  • qu’on met la base à jour
  • qu’on retourne une réponse correcte

Conclusion

Vous trouverez sur Github la version finale avec 4 autres tests (login, register, profile et locale).

Dans ce chapitre on a :

  • préparé les tests avec une base sqlite en mémoire
  • établi les tests pour les catégories
  • établi les tests pour les images

Ainsi se termine cette série ! Il est fort possible que des choses évoluent au niveau du dépôt sur Github en fonction des réactions et remarques.




Créer une application avec Laravel 5.5 – Les langues

Dans ce chapitre on va s’intéresser à l’aspect multi-langage. Pour le moment notre galerie est en français mais on a fait en sorte que les textes soient faciles à traduire en utilisant dans le code les helpers de Laravel. On va donc ajouter maintenant l’anglais à notre galerie. Ça ne concernera évidemment que l’interface et pas les données, ce qui serait une autre histoire…

La configuration

Dans le fichier config/app.php on a des réglages pour les langues :

'locale' => 'fr',

'fallback_locale' => 'en',

On a fixé la locale au français (fr) et la langue par défaut en cas d’absence de traduction à l’anglais (en).

On va ajouter un réglage avec les langues qu’on va mettre en œuvre et qui devront avoir les traductions présentes :

'locales' => ['fr', 'en',],

Route, contrôleur et middleware

Route

On ajoute la route pour le changement de la langue :

Route::name('language')->get('language/{lang}', 'HomeController@language');

Contrôleur

Et on ajoute la fonction dans HomeController :

public function language(String $locale)
{
    $locale = in_array($locale, config('app.locales')) ? $locale : config('app.fallback_locale');

    session(['locale' => $locale]);

    return back();
}

On reçoit une locale en paramètre. Si elle est présente dans le tableau de la configuration on fixe cette locale en session, sinon on se rabat sur la locale par défaut.

Middleware

Il nous faut maintenant un middleware qui va vérifier si on a une locale en session pour réellement l’affecter. Si ce n’est pas le cas essayer de définir la langue de l’utilisateur. On va aussi fixer la locale pour les dates.

php artisan make:middleware Locale

Et on code ainsi :

<?php

namespace App\Http\Middleware;

use Closure;

class Locale
{
    public function handle($request, Closure $next)
    {
        if(!session()->has('locale')) {
            session(['locale' => $request->getPreferredLanguage(config('app.locales'))]);
        }

        app()->setLocale(session('locale'));

        setlocale(LC_TIME, session('locale'));

        return $next($request);
    }
}

Sur un serveur en production vous n’allez pas avoir une langue déclarée avec juste fr ou en. Ça ne marchera donc pas avec ce code. Il faut d’abord vérifier les langues installées avec locale -a. Ensuite on peut créer un tableau de conversion dans le middleware :

$locale = session('locale');

$conversion = [
  'fr' => 'fr_FR',
  'en' => 'en_US',
];

$locale = $conversion[$locale];

setlocale(LC_TIME, $locale);

Et on le référence dans app/Http/Kernel :

protected $middlewareGroups = [
    'web' => [
        ... 

        \App\Http\Middleware\Locale::class,
        \App\Http\Middleware\Settings::class,
    ],

    ...
];

Le menu

On va ajouter un menu déroulant pour le choix de la locale dans views/layouts/app :

<ul class="navbar-nav mr-auto">
    <li class="nav-item dropdown">
        <a class="nav-link" href="#" id="navbarDropdownFlag" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
            <img width="32" height="32" alt="{{ session('locale') }}"  src="{!! asset('images/flags/' . session('locale') . '-flag.png') !!}" />
        </a>
        <div id="flags" class="dropdown-menu" aria-labelledby="navbarDropdownFlag">
            @foreach(config('app.locales') as $locale)
                @if($locale != session('locale'))
                    <a class="dropdown-item" href="{{ route('language', $locale) }}">
                        <img width="32" height="32" alt="{{ session('locale') }}"  src="{!! asset('images/flags/' . $locale . '-flag.png') !!}" />
                    </a>
                @endif
            @endforeach
        </div>
    </li>

Et on va voir le résultat :

Je ne trouve pas trop élégant la largueur de la zone pour le drapeau. On rectifie ça dans assets/css/app.css :

.dropdown-menu {
    min-width: 5rem;
}

On lance npm…

Et c’est maintenant plus équilibré :

Pour le moment le changement de langue ne se voit que dans les validations :

Un package

Il nous faut créer un fichier JSON avec toutes les traductions pour l’anglais. On pourrait faire ça en explorant tout le code avec des copier/coller, ça serait vraiment laborieux !

On va plutôt utiliser un package pour nous aider. Comme je n’en ai pas vraiment trouvé un qui me plaise j’en ai créé un. On va commencer par l’installer :

composer require bestmomo/laravel5-artisan-language --dev

On a maintenant 4 commandes de plus dans artisan :

On va utiliser la deuxième :

php artisan language:make en

Le fichier JSON a été créé et on a tous les textes par ordre alphabétique qui attendent leur traduction :

{
    "Administration": "",
    "Adresse email": "",
    "Ajouter une catégorie": "",
    "Ajouter une image": "",
    "Catégorie": "",

    ...

On ajoute donc les traductions :

{
    "Administration": "Administration",
    "Adresse email": "E-mail address",
    "Ajouter une catégorie": "Add a category",
    "Ajouter une image": "Add an image",
    "Catégorie": "Category",
    "Catégories": "Categories",
    "Cette page n'existe pas": "This page doesn't exist",
    "Confirmation du mot de passe": "Password confirmation",
    "Connexion": "Login",
    "Déconnexion": "Logout",
    "Description (optionnelle)": "Description (optional)",
    "Envoi de la demande": "Send demand",
    "Envoyer": "Send",
    "Erreur 403": "Error 403",
    "Erreur 404": "Error 404",
    "Erreur 503": "Error 503",
    "Gestion des catégories": "Categories gestion",
    "Gérer les catégories": "Categories gestion",
    "Il semble y avoir une erreur sur le serveur, veuillez réessayer plus tard...": "Looks like there is a server issue, please try later...",
    "image orpheline|images orphelines": "orphan image|orphans images",
    "images par page": "images per page",
    "Inscription": "Registration",
    "L'image a bien été enregistrée": "Image has been saved",
    "La catégorie a bien été enregistrée": "Category has been saved",
    "La catégorie a bien été modifiée": "Category has been updated",
    "Le profil a bien été mis à jour": "Profile has been updated",
    "Maintenance": "Maintenance",
    "Modifer le profil": "Profile update",
    "Modifier la catégorie": "Update the category",
    "Modifier une catégorie": "Update a category",
    "Mot de passe": "Password",
    "Mot de passe oublié ?": "Password forgotten?",
    "Nom": "Name",
    "Non": "No",
    "Oui": "Yes",
    "Pagination : ": "Pagination: ",
    "Photos de ": "Photos from ",
    "Profil": "Profile",
    "Renouvellement du mot de passe": "Password reset",
    "Renouveller": "Reset",
    "Se rappeler de moi": "Remember me",
    "Service temporairement indisponible ou en maintenance": "The server is currently unavailable",
    "Supprimer": "Delete",
    "Supprimer cette photo": "Delete this photo",
    "Supprimer la catégorie": "Delete the category",
    "Voir les photos de ": "Show photos from ",
    "Vos droits d'accès ne pous permettent pas d'accéder à cette ressource": "You might not have the necessary permissions for this resource",
    "Vraiment supprimer cette catégorie ?": "Really delete this category?",
    "Vraiment supprimer toutes les photos orphelines ?": "Really delete all orphans images?"
}

Maintenant si on passe à l’anglais on a bien les textes dans cette langue :

Les dates

Pour le moment les dates ne sont pas très élégantes :

Et elles sont toujours au format américain. On a prévu ce code dans views/home :

{{ $image->created_at }}

On va se servir d’une méthode de Carbon pour arranger ça (on va négliger l’heure) :

{{ $image->created_at->formatLocalized('%x') }}

Maintenant en français j’obtiens :

Et en anglais :

Conclusion

Dans ce chapitre on a :

  • prévu la configuration pour les locales
  • ajouté la route, la fonction du contrôleur et un middleware pour le changement de locale
  • ajouté un menu déroulant avec les drapeaux des langues disponibles
  • installé un package pour créer le fichier de la nouvelle langue et ajouté ainsi les traductions
  • adapté les dates à la locale dans la vue de la galerie

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.




Créer une application avec Laravel 5.5 – Le profil

Maintenant notre galerie est opérationnelle il ne nous reste plus qu’à un peu peaufiner tout ça. Dans ce chapitre nous allons mettre en place une page de profil pour les utilisateurs pour leur permettre de modifier leur adresse courriel et la pagination.

Les données

Il nous faut compléter la table users pour ajouter la valeur de la pagination. On reprend donc la migration :

public function up()
{
    Schema::create('users', function (Blueprint $table) {
        
        ...

        $table->json('settings');
        
        ...        

    });
}

On se réserve la possibilité d’ajouter d’autres éléments dans le futur on opte pour un format JSON.

Si on avait une table déjà remplie d’informations sur un site existant on ferait une migration complémentaire pour juste ajouter la nouvelle colonne.

On met à jour le seeder pour la table users pour affecter une valeur à la colonne settings :

public function run()
{
    User::create([
        'name' => 'Durand',
        'email' => 'durand@chezlui.fr',
        'role' => 'admin',
        'password' => bcrypt('admin'),
        'settings' => '{"pagination": 8}',
    ]);

    User::create([
        'name' => 'Dupont',
        'email' => 'dupont@chezlui.fr',
        'password' => bcrypt('user'),
        'settings' => '{"pagination": 8}',
    ]);
}

On complète aussi RegisterController :

protected function create(array $data)
{
    return User::create([
        'name' => $data['name'],
        'email' => $data['email'],
        'password' => bcrypt($data['password']),
        'settings' => '{"pagination": 8}',
    ]);
}

On relance les migrations pour rafraîchir notre base :

php artisan migrate:fresh --seed

Et on vérifie que la colonne a bien été créée dans la table.

Le contrôleur et les routes

On crée un nouveau contrôleur :

php artisan make:controller UserController --resource

On va conserver seulement les fonctions edit et update et ajouter la référence du modèle :

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;

class UserController extends Controller
{
    public function edit($id)
    {
        //
    }

    public function update(Request $request, $id)
    {
        //
    }
}

Et on complète les routes :

Route::middleware('auth')->group(function () {

    Route::resource('profile', 'UserController', [
        'only' => ['edit', 'update'],
        'parameters' => ['profile' => 'user']
    ]);

    ...

});

Le menu

Il nous faut encore ajouter un item dans le menu (views/layouts/app) réservé aux utilisateurs connectés :

@guest
    ...
@else
    <li class="nav-item{{ currentRoute(route('profile.edit', auth()->id())) }}"><a class="nav-link" href="{{ route('profile.edit', auth()->id()) }}">@lang('Profil')</a></li>
    ...
@endguest

La vue

On crée un dossier et une vue pour le formulaire :

Avec ce code pour la vue :

@extends('layouts.form')

@section('css')

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/10.0.0/css/bootstrap-slider.min.css">

@endsection

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Modifer le profil')
        @endslot

        <form method="POST" action="{{ route('profile.update', $user->id) }}">
            {{ csrf_field() }}
            {{ method_field('PUT') }}

            @include('partials.form-group', [
                'title' => __('Adresse email'),
                'type' => 'email',
                'name' => 'email',
                'required' => true,
                'value' => $user->email,
                ])

            <div class="form-group">
                @lang('Pagination : ')<span id="nbr">{{ $settings->pagination }}</span> @lang('images par page')<br>
                <input id="pagination" name="pagination" type="number" data-slider-min="3" data-slider-max="20" data-slider-step="1" data-slider-value="{{ $settings->pagination }}"/><br>                
            </div>

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form> 

    @endcomponent            

@endsection

@section('script')

    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.9.0/bootstrap-slider.min.js"></script>

    <script>
        $(function() {
            $("#pagination")
            .slider()
            .on("slide", function(e) {
                $("#nbr").text(e.value)
            })
            .on("change", function(e) {
                $("#nbr").text(e.value.newValue)
            })
        })
    </script>

@endsection

Boostrap 4 n’est pas équipé d’un slider alors on utilise celui-ci :

Il y a plusieurs exemples de mise en œuvre sur le site.

Comme on s’en sert que sur cette page on ne va pas l’ajouter dans les assets mais juste le charger par un CDN.

Le formulaire et sa soumission

L’affichage du formulaire

On complète UserController :

public function edit(User $user)
{
    $settings = json_decode($user->settings);

    return view ('users.edit', compact('user', 'settings'));
}

Et le formulaire peut maintenant s’afficher :

La soumission

On commence par compléter le modèle User pour l’assignation de masse avec la colonne settings :

protected $fillable = [
    'name', 'email', 'password', 'settings',
];

On complète ensuite UserContrloller pour traiter la soumission :

public function update(Request $request, User $user)
{
    $request->validate([
        'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
        'pagination' => 'required',
    ]);

    $user->update([
        'email' => $request->email,
        'settings' => json_encode(['pagination' => $request->pagination]),
    ]);

    return back()->with(['ok' => __('Le profil a bien été mis à jour')]);
}

Et on vérifie que ça fonctionne !

Un middleware

Ce n’est pas parce que maintenant la valeur de la pagination est fixée dans la table que ça va magiquement la changer dans la galerie !

On crée un nouveau middleware :

php artisan make:middleware Settings

Et on code ainsi :

<?php

namespace App\Http\Middleware;

use Closure;

class Settings
{
    public function handle($request, Closure $next)
    {
        if(auth()->check()) {
            $settings = json_decode(auth()->user()->settings);

            config(['app.pagination' => $settings->pagination]);
        }

        return $next($request);
    }
}

Si le visiteur est authentifié on récupère son réglage de pagination et on actualise la configuration.

On déclare ce middleware dans app/Http/Kernel :

protected $middlewareGroups = [
    'web' => [

        ...

        \App\Http\Middleware\Settings::class,
    ],

    ...
];

Et maintenant la pagination personnalisée doit fonctionner !

Une autorisation

Il nous reste à sécuriser la modification du profil. En effet il est pour le moment assez facile d’usurper une identité pour modifier le profil d’une autre utilisateur. On va donc créer un police :

php artisan make:policy UserPolicy --model=User

On va conserver que la fonction update et coder ainsi :

<?php

namespace App\Policies;

use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class UserPolicy
{
    use HandlesAuthorization;

    public function update(User $user, User $userprofile)
    {
        return $user->id === $userprofile->id;
    }
}

On renseigne AuthServiceProvider :

use App\Policies\ { ImagePolicy, UserPolicy };
use App\Models\ { Image, User };

...

protected $policies = [
    Image::class => ImagePolicy::class,
    User::class => UserPolicy::class,
];

Et on complète UserController :

public function update(Request $request, User $user)
{
    $this->authorize('update', $user);

    ...

Et maintenant on est tranquilles…

Conclusion

Dans ce chapitre on a :

  • complété la migration de la table users pour la pagination personnalisée
  • ajouté un tiem dans le menu pour le profil
  • créé un contrôleur et les routes pour le profil
  • créé le formulaire de modification du profil
  • codé le traitement du formulaire
  • ajouté un middleware pour rendre effective la pagination personnalisée
  • ajouté une autorisation pour la modification du profil

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.




Créer une application avec Laravel 5.5 – La galerie 2/2

Dans le précédent chapitre on a affiché la galerie en prévoyant la possibilité d’une présentation par catégorie. On va dans ce chapitre compléter avec une présentation par utilisateur. D’autre part on va mettre en place le code pour la suppression des photos. On va également créer les pages pour les erreurs les plus classiques. On finira avec la suppression des images orphelines.

Les photos d’un utilisateur

Dans la galerie pour chaque photo on a le nom de celui qui l’a envoyée. C’est un lien avec un popup au survol :

Pour le moment on n’a pas référencé l’url correspondante (views/home) :

<a href="#" data-toggle="tooltip" title="{{ __('Voir les photos de ') . $image->user->name }}">{{ $image->user->name }}</a>

On commence par ajouter une route :

Route::name('user')->get('user/{user}', 'ImageController@user');

On crée la fonction dans ImageController :

use App\Models\ { Category, User };

...

public function user(User $user)
{
    $images = $this->repository->getImagesForUser($user->id);

    return view('home', compact('user', 'images'));
}

La fonction dans ImageRepository :

public function getImagesForUser($id)
{
    return Image::latestWithUser()->whereHas('user', function ($query) use ($id) {
        $query->whereId($id);
    })->paginate(config('app.pagination'));
}

Et on complète la vue (views/home) :

<a href="{{ route('user', $image->user->id) }}" data-toggle="tooltip" title="{{ __('Voir les photos de ') . $image->user->name }}">{{ $image->user->name }}</a>

Et maintenant on peut cliquer sur le nom :

Supprimer une photo

Quand un utilisateur est connecté on lui permet de supprimer ses photos (et s’il est administrateur il peut toutes les supprimer) :

Dans la vue (views/home) c’est cette partie du code qui est concernée :

@adminOrOwner($image->user_id)
    <a class="form-delete" href="{{ route('image.destroy', $image->id) }}" data-toggle="tooltip" title="@lang('Supprimer cette photo')"><i class="fa fa-trash"></i></a>
    <form action="{{ route('image.destroy', $image->id) }}" method="POST" class="hide">
        {{ csrf_field() }}
        {{ method_field('DELETE') }}
    </form>
@endadminOrOwner

...

$('a.form-delete').click(function(e) {
    e.preventDefault();
    let href = $(this).attr('href')
    $("form[action='" + href + "'").submit()
})

On utilise la directive Blade qu’on a créée au précédent chapitre (@adminOrOwner) pour faire apparaître l’icône pour les utilisateurs concernés.

On a un formulaire et une soumission par Javascript.

On a déjà créé la route précédemment dans cette ressource :

Route::resource('image', 'ImageController', [
    'only' => ['create', 'store', 'destroy']
]);

On met ce code dans ImageController :

use App\Models\ { Category, User, Image };

...

public function destroy(Image $image)
{
    $image->delete();

    return back();
}

Et maintenant si on clique sur la petite poubelle l’image disparaît.

Mais on a quand même un petit souci de sécurité. ce n’est pas parce qu’on n’affiche pas une icône aux autres utilisateurs qu’ils ne sont pas capable de générer une requête pour supprimer une photo, même si ça demande quelques connaissances…

On va donc ajouter une autorisation pour verrouiller cette possibilité :

php artisan make:policy ImagePolicy

Avec ce code :

<?php

namespace App\Policies;

use App\Models\ { User, Image };
use Illuminate\Auth\Access\HandlesAuthorization;

class ImagePolicy
{
    use HandlesAuthorization;

    /**
     * Grant all abilities to administrator.
     *
     * @param  \App\Models\User  $user
     * @return bool
     */
    public function before(User $user)
    {
        if ($user->role === 'admin') {
            return true;
        }
    }

    /**
     * Determine whether the user can delete the image.
     *
     * @param \App\Models\User $user
     * @param \App\Models\Image $image
     * @return mixed
     */
    public function delete(User $user, Image $image)
    {
        return $user->id === $image->user_id;
    }
}

Dans la fonction before on autorise les administrateurs et dans la fonction delete l’owner.

On l’enregistre dans AuthServiceProvider :

use App\Policies\ImagePolicy;
use App\Models\Image;

...

protected $policies = [
    Image::class => ImagePolicy::class,
];

Et on l’ajoute dans ImageController :

public function destroy(Image $image)
{
    $this->authorize('delete', $image);

    $image->delete();

    return back();
}

Maintenant on est sûrs qu’une petit malin ne pourra pas supprimer une photo qui ne lui appartient pas !

Pour vérifier que ça fonctionne supprimez la directive @adminOrOwner dans la vue home, connectez-vous avec Dupont tentez de supprimer une photo de Durand :

Ce n’est pas élégant mais efficace !

Les pages d’erreur

On va en profiter pour améliorer l’affichage des erreurs comme celle vue ci-dessus.

On créer un dossier spécifique et un layout :

Avec ce code (inspiré de cet exemple de Bootstrap 4) :

@extends('layouts.app')

@section('css')

  <style>

    html,
    body {
      height: 100%;
    }
    body {
      color: white;
      text-align: center;
    }
    .site-wrapper {
      display: table;
      width: 100%;
      height: 100%;
      min-height: 100%;
    }
    .site-wrapper-inner {
      display: table-cell;
      vertical-align: middle;
      margin-right: auto;
      margin-left: auto;
      width: 100%;
      padding: 0 1.5rem;
    }

  </style>

@endsection

@section('content')

  <div class="site-wrapper">
    <main role="main" class="site-wrapper-inner">
      <h1>@yield('title')</h1>
      <p class="lead">@yield('text')</p>
    </main>
  </div>

@endsection

On ajoute une vue 403 :

Avec ce code :

@extends('errors.base')

@section('title')
  @lang('Erreur 403')
@endsection

@section('text')
  @lang("Vos droits d'accès ne pous permettent pas d'accéder à cette ressource")
@endsection

On a maintenant quelque chose de plus joli :

On va ajouter aussi 404 :

@extends('errors.base')

@section('title')
  @lang('Erreur 404')
@endsection

@section('text')
  @lang("Cette page n'existe pas")
@endsection

Et 503 :

@extends('errors.base')

@section('title')
  @lang('Erreur 503')
@endsection

@section('text')
  @lang("Service temporairement indisponible ou en maintenance")
@endsection

Les images orphelines

Lorsqu’on supprime une image tel qu’on l’a fait ci-dessus ça a pour effet de supprimer la ligne dans la table images mais les deux versions de la photo (haute et basse résolution) restent sur le disque. Ce n’est pas vraiment gênant mais ça pourrait le devenir en cas de nombreuses suppression et puis ça serait quand même plus élégant de s’en occuper.

On pourrait ajouter cette action systématiquement quand on supprime une photo mais j’ai préféré créer une partie maintenance réservée à l’administrateur.

On va créer deux nouvelles routes :

Route::middleware('admin')->group(function () {

    ...

    Route::name('maintenance.index')->get('maintenance', 'AdminController@index');
    Route::name('maintenance.destroy')->delete('maintenance', 'AdminController@destroy');

});

On ajoute un item au menu de l’administration (views/layouts/app) :

@admin
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle{{ currentRoute(
                        route('category.create'), 
                        route('category.index'),
                        route('category.edit', request()->category),
                        route('maintenance.index')
                    )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
            @lang('Administration')
        </a>
        <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
            <a class="dropdown-item" href="{{ route('category.create') }}">
                <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
            </a>
            <a class="dropdown-item" href="{{ route('category.index') }}">
                <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
            </a>
            <a class="dropdown-item" href="{{ route('maintenance.index') }}">
                <i class="fas fa-cogs fa-lg"></i> @lang('Maintenance')
            </a>
        </div>
    </li>
@endadmin

Et on a le nouvel item :

On crée un nouveau contrôleur :

php artisan make:controller AdminController --resource

On va utiliser ImageRepository et conserver les fonctions index et destroy :

<?php

namespace App\Http\Controllers;

use App\Repositories\ImageRepository;

class AdminController extends Controller
{
    protected $repository;

    public function __construct(ImageRepository $repository)
    {
        $this->repository = $repository;
    }

    public function index()
    {
        //
    }

    public function destroy($id)
    {
        //
    }
}

Affichage des orphelines

Pour l’affichage des orphelines on code la méthode index :

public function index()
{
    $orphans = $this->repository->getOrphans ();
    $countOrphans = count($orphans);

    return view('maintenance.index', compact ('orphans', 'countOrphans'));
}

Et dans ImageRepository :

public function getOrphans()
{
    $files = collect(Storage::disk('images')->files());
    $images = Image::select('name')->get()->pluck('name');
    return $files->diff($images);
}

On en profite pour voir la puissance des collections de Laravel !

On crée la vue :

Avec ce code :

@extends('layouts.app')

@section('content')

    <main class="container-fluid">
        <h1>
            {{ $countOrphans }} {{ trans_choice(__('image orpheline|images orphelines'), $countOrphans) }}
            @if($countOrphans)
                <a class="btn btn-danger pull-right" href="{{ route('maintenance.destroy') }}" role="button">@lang('Supprimer')</a>
            @endif
        </h1>

        <div class="card-columns">
            @foreach($orphans as $orphan)
                <div class="card">
                    <img class="img-fluid" src="{{ url('thumbs/' . $orphan) }}" alt="image">
                </div>
            @endforeach
        </div>
    </main>

@endsection

@section('script')

    <script>
        $(function() {

            $.ajaxSetup({
                headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }
            })

            $('a.btn-danger').click(function(e) {
                let that = $(this)
                e.preventDefault()
                swal({
                    title: '@lang('Vraiment supprimer toutes les photos orphelines ?')',
                    type: 'warning',
                    showCancelButton: true,
                    confirmButtonColor: '#DD6B55',
                    confirmButtonText: '@lang('Oui')',
                    cancelButtonText: '@lang('Non')'
                }).then(function () {
                    $.ajax({
                        url: that.attr('href'),
                        type: 'DELETE'
                    })
                        .done(function () {
                            location.reload();
                        })
                        .fail(function () {
                            swal({
                                title: '@lang('Il semble y avoir une erreur sur le serveur, veuillez réessayer plus tard...')',
                                type: 'warning'
                            })
                        }
                    )
                })
            })
        })
    </script>

@endsection

Suppression des orphelines

On a un bouton pour la suppression :

On code AdminController :

public function destroy()
{
    $this->repository->destroyOrphans ();

    return response()->json();
}

Et ImageRepository :

public function destroyOrphans()
{
    $orphans = $this->getOrphans ();

    foreach($orphans as $orphan) {
        Storage::disk('images')->delete($orphan);
        Storage::disk('thumbs')->delete($orphan);
    }
}

Dans la vue on a une alerte :

Si on supprime on se retrouve avec ça :

Remarquez la gestion du pluriel au niveau de la vue :

__('image orpheline|images orphelines')

Conclusion

Dans ce chapitre on a :

  • affiché les photos par utilisateur
  • codé la suppression des photos en prévoyant une autorisation
  • ajouté les pages d’erreurs les plus usuelles
  • ajouté la gestion des images orpheline dans l’administration

Pour vous simplifier la vie vous pouvez charger le projet dans son état à l’issue de ce chapitre.