V této sérii o dvou dílech si ukážeme, jak v Laravelu nahrát soubor. Framework tento proces umožňuje provést velmi jednoduše a v prvním díle si vysvětlíme jednoduchý upload obrázku. V dalším díle pak použijeme externí knihovnu, díky níž můžeme obrázek navíc jednoduše upravit.

Rád bych ale kromě uploadu také ukázal validaci a ukládání do databáze, nicméně v rámci návodu se budu snažit provést celý proces co nejjednodušeji to jde.

Úvod

Výsledkem tohoto článku bude jednoduchý seznam příspěvků, které budou mít uvodní obrázek. Jako první si vytvoříme migrace, model a controller. Poté si vytvoříme pohled, kde si vytvoříme velmi primitivní formulář, ve kterém bude možné napsat nějaký text a nahrát úvodní obrázek.

Postupů jak ukládat obrázky, resp. soubory, je mnoho a neexistuje žádný definitivní postup jak toho dosáhnout. V tomto článku nahrávání obrázků budu popisovat tak, jak bych to řešil já. Jedná se však pouze o demonstraci a věřím, že každý si pak dokáže zbytek upravit podle vlastní potřeby. Aby vzhled nevypadal moc jednoduše, použiji css framework Tailwind CSS. Pracuje se s nim velmi jednoduše a i když je trochu ukecanější, o to lehčeji se pak čte, jaké vlastnosti prvek má.

Migrace a model

Jako první si vytvoříme migraci pro vytvoření tabulky posts. Jedná se o jednoduchý příklad, proto tabulka bude prakticky obsahovat mimo jiné sloupce content pro obsah článku a image_path, kde bude cesta k obrázku:

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

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->increments('id');
            $table->text('content');
            $table->text('image_path');
            $table->timestamps();
        });
    }

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

A poté si vytvoříme model:

namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    //
}

Controller a routy

Celkem budeme potřebovat tři routy - výpis všech článků, zobrazení formuláře a odeslání formuláře. Rovnou si tedy tyto routy vytvoříme v routes/web.php:

Route::get('/', 'PostController@index');
Route::get('/form', 'PostController@form');
Route::post('store', 'PostController@store');

A nakonec si vytvoříme controller PostController, který na základě rout bude obsahovat tři metody:

namespace App\Http\Controllers;

use App\Post;
use Illuminate\Http\Request;
use Illuminate\View\View;

class PostController extends Controller
{
    public function index()
    {
        //
    }

    public function form()
    {
        //
    }

    public function store(Request $request)
    {
        //
    }
}

Zobrazení a zpracování formuláře

V PostControlleru si upravíme metodu form(), aby se vygeneroval pohled:

public function form(): View
{
    return view('form');
}

V resources/views si vytvoříme nový pohled form.blade.php a vyplníme jej následujícím obsahem:

<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Vytvořit nový článek</title>

  <!-- tailwindcss -->
  <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
<div class="content">
  @if ($errors->any())
    @foreach ($errors->all() as $error)
      <div class="bg-red-lightest border border-red-light text-red-dark px-4 py-3 rounded relative" role="alert">
        <span class="block sm:inline">{{ $error }}</span>
      </div>
    @endforeach
  @endif

  <div class="flex items-center h-screen w-full">
    <div class="w-full bg-white rounded shadow-lg p-8 m-4 md:max-w-sm md:mx-auto">
      <h1 class="block w-full text-center text-grey-darkest mb-6">Vytvořit nový článek</h1>
      <form class="mb-4 md:flex md:flex-wrap md:justify-between" action="/store" method="post" enctype="multipart/form-data">
        @csrf
        <div class="flex flex-col mb-4 md:w-full">
          <label class="mb-2 uppercase font-bold text-lg text-grey-darkest" for="text">Text</label>
          <textarea rows="5" class="border py-2 px-3 text-grey-darkest" type="email" name="text" id="text"></textarea>
        </div>
        <div class="flex flex-col mb-6 md:w-full">
          <label class="mb-2 uppercase font-bold text-lg text-grey-darkest" for="image">Obrázek</label>
          <input class="border py-2 px-3 text-grey-darkest" type="file" name="image" id="image">
        </div>
        <button class="block bg-teal hover:bg-teal-dark text-white uppercase text-lg mx-auto p-4 rounded" type="submit">Vytvořit</button>
      </form>
      <a class="block w-full text-center no-underline text-sm text-grey-dark hover:text-grey-darker" href="/">Zpět na články</a>
    </div>
  </div>
</div>
</body>
</html>

Jak jsem již psal, jedná se o velmi jednoduchou aplikaci. Jako první kontrolujeme, jestli nám aplikace nevrátila nějakou chybu, nejčastěji pak v případě validace. Poté si definujeme formulář, kde je důležité nezapomenout na enctype="multipart/form-data", který nám zajistí, že se správně odešle i soubor. Protože posíláme formulář metodou POST, je potřeba také vygenerovat csrf token, který Laravel vyžaduje. Toho jednoduše docílíme helperem @csrf, který vygeneruje hidden input s tokenem. A nakonec si definujeme samotné pole pro obsah článku a výběr obrázku.

Po odeslání se zavolá routa /store, která požadavek přesměruje do PostControlleru a do metody store(). Jako první bychom měli data z formuláře zvalidovat, jestli opravdu posíláme to, co chceme. Půjdu jednodušší cestou a validaci provedu rovnou v controlleru:

$this->validate($request, [
    'text'  => 'required',
    'image' => 'image|max:1999'
]);

Především si všimněte validace obrázku. Pro validaci využívám pravidlo image, které nám zajistí, že soubor bude mít příponu jpeg, png, bmp, gif, nebo svg. Pravidlo max určuje, že velikost souboru nepřekročí 2MB. Pravidel samozřejmě můžeme nastavit více, celý seznam najdete zde. Pro náš příklad to ale stačí.

Další částí, kterou potřebujeme udělat, je samotné uložení obrázku. Laravel nám velmi jednoduše nabízí, jak to udělat:

$path = $request->image->store('uploads/posts-images');

Popíšu co předchozí kód dělá. Jako první si z requestu vytáhnu soubor, v našem případě obrázek, který jsme poslali z formuláře. Na tento objekt zavoláme metodu store(), která jako parametr vyžaduje cestu, kde se má soubor vygenerovat. Laravel vytvoří náhodný hash a soubor tak pojmenuje. Nemůže se tedy stát, že bychom soubor nechtěně přepsali jiný souborem, který má stejný název.

A teď si určitě říkáte, no jo, ale kde se soubor vlastně uložil? Tady se to začíná trošku komplikovat, ale není to zase tak složité. Framework ve výchozím nastavení ukládá soubory automaticky do adresáře storage/app/. To je vytvořeno zámerně, aby nebyly nahrané soubory uživatelů tak snadno přístupné a brání tak nevyžádanému přístupu. Vzniká ale tak problém jak soubory zobrazit, když adresář /storage je pro prohlížeč nepřístupný? Abychom adresář udělali veřejný, potřebujeme udělat dvě věci:

1) Změnit nastavení disku. Změňte v config/filesystems.php parametr default z local na public. Poté soubory budou automaticky ukládány do storage/app/public.

2) Je potřeba vytvořit symlink z /public/storage do /storage/app/public pomocí artisan příkazu:

php artisan storage:link

Zde je informace z oficiální dokumentace:

By default, the public disk uses the local driver and stores these files in storage/app/public. To make them accessible from the web, you should create a symbolic link from public/storage to storage/app/public.

Poté budeme mít přístupné i soubory, které se uloží do adresáře storage/app/public.

Pokud bychom nechtěli, aby nám Laravel vygeneroval pro obrázek náhodný název, můžeme použít metodu storeAs(), která jako první parametr vyžaduje název cesty a ve druhém parametru definujete název souboru:

$request->image->storeAs('uploads/posts-images', '1.png');

Nebo můžeme ponechat původní název obrázku:

$request->image->storeAs('uploads/posts-images', $request->image->getClientOriginalName());

Tímto jsme si uložili soubor a metoda store() navíc vrací adresu, kde byl soubor uložen, proto si výsledek rovnou uložíme do proměnné. Poslední věcí co nám zbývá, je uložení do databáze:

$post = new Post();
$post->content = $request->text;
$post->image_path = $path;
$post->save();

A nakonec po uložení přesměrujeme uživatele zpět na domovskou stránku. Celá metoda store() tak vypadá takto:

public function store(Request $request): RedirectResponse
{
    $this->validate($request, [
        'text' => 'required',
        'image' => 'image|max:1999'
    ]);

    $path = $request->file('image')->store('uploads/posts-images');

    $post = new Post();
    $post->content = $request->text;
    $post->image_path = $path;
    $post->save();

    return redirect('/');
}

Zobrazení všech článků

Na závěr už jen zobrazíme všechny vytvořené články. V PostControlleru si upravíme metodu index(), aby nám vrátila pohled i se všemi články:

public function index(): View
{
    $posts = Post::all();

    return view('posts', compact('posts'));
}

V resources/views si vytvoříme pohled posts.blade.php s následujícím obsahem:

<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Příspěvky</title>

  <!-- tailwindcss -->
  <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>
<section class="bg-white py-4 font-sans">
  <div class="flex items-center justify-center w-full m-4 text-xl">
    <a class="bg-white hover:bg-grey-lightest text-grey-darkest font-semibold py-2 px-4 border border-grey-light rounded shadow no-underline" href="/form">
      Vytvořit nový článek
    </a>
  </div>
  <div class="container max-w-xl m-auto flex flex-wrap items-center justify-start">

    @foreach($posts as $post)
      <div class="w-full md:w-1/2 lg:w-1/3 flex flex-col mb-8 px-3">
        <div class="overflow-hidden bg-white rounded-lg shadow hover:shadow-raised hover:translateY-2px transition">
          <img class="w-full" src="{{ '/storage/' . $post->image_path }}">
          <div class="p-6 flex flex-col justify-between ">
            <h3 class="font-medium text-grey-darkest mb-4 leading-normal">{{ $post->content }}</h3>
          </div>
        </div>
      </div>
    @endforeach
  </div>
</section>

</body>
</html>

Nejdůležitější částí je adresa obrázku. V databázi máme uloženou adresu obrázku jako uploads/posts-images/hash_obrazku.jpg. Abychom obrázek správně zobrazili, je potřeba přidat ještě adresář storage, pro který jsme si dříve vytvořili symlink.

A tím máme celé nahrávání a zobrazení obrázku hotové. Když se na to podíváte, tak vlastně celá logika ukládání obrázku se odehrává na jediném řádku. Laravel umožňuje kromě ukládání na lokální disk také ukládát do cloudu Amazon S3 nebo Rackspace. Více o tom ale najdete v oficiální dokumentaci.

V dnešním článku jsme si ukázali jednoduchý upload obrázku, který uloží obrázek přesně tak, jak jsme ho nahráli. Co když ale potřebujeme, aby se obrázek před uložení zmenšil, ořezal, či jinak upravil? V dalším díle se podíváme na knihovnu Intervention Image, která nám všechny tyto problémy hravě vyřeší.

Není vám něco jasné, mám někde chybu nebo chcete přidat svůj názor na článek? Napište do komentářů pod článkem.