Tutorial: your first upload
In this tutorial you build one page: the photos of a product. On that page you pick a photo, add a caption and press Upload. The page then shows a small thumbnail of every photo, links it to a larger version and has a button to remove it.
It takes about twenty minutes. Every step is something you type or paste; nothing is left out. When a step uses a Laravel feature, a sentence says what it does, so you can follow along even when this is your first Laravel project. How it works explains the ideas behind the package in more detail.
What you need
-
PHP 8.2 or higher with the GD extension. GD is the part of PHP that resizes images. Check it in a terminal:
php -v php -mThe first line of
php -vshows the version;php -mlists the extensions, andgdhas to be in that list. - Composer, the package manager of PHP.
composer --versionshould print a version. -
A Laravel application. Start a new one if you do not have one yet:
composer create-project laravel/laravel shop cd shopA new Laravel application uses SQLite, a database that is a single file in
database/, so there is no database server to set up.
From here on every command runs in the folder of your application.
Step 1. Install the package
composer require darvis/laravel-uploads
php artisan migrate
php artisan storage:link
composer requiredownloads the package. Laravel finds it by itself; there is nothing to register.php artisan migratecreates theuploadstable. Every file you upload becomes one row in that table.php artisan storage:linkmakes the folderstorage/app/publicreachable from the browser as/storage. Photos that everyone may see are stored there.
Now open the file .env in the root of your application and add this line at the bottom:
UPLOADS_QUEUE_CONNECTION=sync
After an upload the package makes smaller versions of the photo, the conversions. Normally that happens in the background, in a queue, so the visitor does not wait for it. sync means “do it right away, during the upload”. That is the easiest setting while you learn; step 8 switches to the background.
Step 2. Create the products
A product needs a table and a model. Laravel makes both with one command:
php artisan make:model Product -m
This creates app/Models/Product.php and, because of -m, a migration in database/migrations/ whose name ends in _create_products_table.php. Open that migration and add a name column:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Run the migration to create the table:
php artisan migrate
Now open app/Models/Product.php and replace its contents with:
<?php
namespace App\Models;
use Darvis\Uploads\Models\Concerns\HasUploads;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasUploads;
protected $fillable = ['name'];
}
use HasUploads; is the line that matters: it gives every product the methods to store and read uploads, such as addUpload() and uploadsIn(). $fillable allows the name to be filled in when you create a product.
Create a first product. php artisan tinker opens a PHP prompt inside your application:
php artisan tinker
App\Models\Product::create(['name' => 'Desk lamp']);
Type exit to leave tinker. The product has id 1.
Step 3. The routes
A route connects an address in the browser to code. Open routes/web.php and add these lines at the bottom, and the use line at the top under the other use line:
use App\Http\Controllers\ProductPhotoController;
Route::get('/products/{product}/photos', [ProductPhotoController::class, 'index'])->name('products.photos.index');
Route::post('/products/{product}/photos', [ProductPhotoController::class, 'store'])->name('products.photos.store');
Route::delete('/products/{product}/photos/{upload}', [ProductPhotoController::class, 'destroy'])->name('products.photos.destroy');
Three routes: one shows the page, one receives the form with the photo, and one removes a photo. {product} is the id of the product in the address; Laravel looks the product up for you.
Step 4. The controller
The controller holds the code behind the routes. Create it:
php artisan make:controller ProductPhotoController
Open app/Http/Controllers/ProductPhotoController.php and replace its contents with:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProductPhotoController extends Controller
{
public function index(Product $product): View
{
return view('products.photos', [
'product' => $product,
'photos' => $product->uploadsIn('photos')->get(),
]);
}
public function store(Request $request, Product $product): RedirectResponse
{
$request->validate([
'photo' => ['required', 'image', 'max:10240'],
'caption' => ['nullable', 'string', 'max:255'],
]);
$product->addUpload($request->file('photo'), 'photos', public: true, meta: [
'caption' => $request->input('caption'),
]);
return back()->with('status', 'The photo has been uploaded.');
}
public function destroy(Product $product, int $upload): RedirectResponse
{
$product->uploadsIn('photos')->findOrFail($upload)->delete();
return back()->with('status', 'The photo has been removed.');
}
}
What each method does:
indexfetches the photos of this product and passes them to the page.uploadsIn('photos')means “the uploads of this product in the collectionphotos”; a collection is just a name you choose to keep different kinds of files apart.->get()runs the query and returns the photos, in the order they were added.storefirst checks the form: a photo is required, it has to be an image and it may be at most 10240 kilobytes (10 MB). When the check fails, Laravel goes back to the page with the error messages by itself. ThenaddUpload()stores the file in the collectionphotos,public: truemeans everyone may see it, andmetakeeps the caption with the photo.destroylooks the photo up within this product’s photos, so nobody can remove a photo of another product by changing the number in the address, and moves it to the trash. The files stay until the trash is emptied, see Administration & trash.
Step 5. The page
Create the folder resources/views/products and in it the file photos.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Photos of <?= e($product->name) ?></title>
</head>
<body>
<h1>Photos of <?= e($product->name) ?></h1>
@if (session('status'))
<p><?= e(session('status')) ?></p>
@endif
@if ($errors->any())
<ul>
@foreach ($errors->all() as $error)
<li><?= e($error) ?></li>
@endforeach
</ul>
@endif
<form method="POST" action="<?= e(route('products.photos.store', $product)) ?>" enctype="multipart/form-data">
@csrf
<input type="file" name="photo" accept="image/*">
<input type="text" name="caption" placeholder="Caption (optional)">
<button type="submit">Upload</button>
</form>
@foreach ($photos as $photo)
<figure>
<a href="<?= e($photo->conversionUrl('preview')) ?>">
<img src="<?= e($photo->thumbnailUrl()) ?>" alt="<?= e($photo->meta('caption') ?: $photo->name) ?>">
</a>
<figcaption><?= e($photo->meta('caption') ?: $photo->name) ?> (<?= e($photo->humanSize()) ?>)</figcaption>
<form method="POST" action="<?= e(route('products.photos.destroy', [$product, $photo->id])) ?>">
@csrf
@method('DELETE')
<button type="submit">Remove</button>
</form>
</figure>
@endforeach
</body>
</html>
In Blade you normally print a value between double curly braces. This documentation site cannot show those braces, so the examples use <?= e(...) ?>, which does exactly the same: e() escapes the value, so a caption with HTML in it is shown as text instead of run as code. You can paste the examples as they are, or write the double curly braces yourself. Never leave out the e().
Three details in the form that are easy to forget, and each one breaks the upload when it is missing:
enctype="multipart/form-data"tells the browser to send the file itself. Without it only the file name arrives, and the validation says “The photo field must be an image.”@csrfadds a hidden token that proves the form came from your own site. Without it Laravel answers with 419 Page Expired.@method('DELETE')on the remove form: a browser can only send GET and POST, so Laravel reads the real method from this hidden field.
On the photo itself:
thumbnailUrl()is the address of the small version (300 × 300 pixels).conversionUrl('preview')is the address of the larger version (1200 pixels wide).meta('caption')reads the caption you stored;?: $photo->namefalls back to the original file name when there is no caption.humanSize()turns the size into something readable, like1.2 MB.
Step 6. Try it
Start the development server:
php artisan serve
Open http://localhost:8000/products/1/photos, pick a photo, type a caption and press Upload. The page comes back with “The photo has been uploaded.” and a thumbnail. Click the thumbnail to see the larger version, and try Remove.
Try the checks as well: press Upload without a photo, or pick a text file. The page shows the error message and stores nothing.
Step 7. Look at what was stored
Open the folder storage/app/public/products/1/photos. For one photo you will find three files:
7131d4ec-e520-4129-b964-05a803fde311.jpg the original
7131d4ec-e520-4129-b964-05a803fde311-thumb.webp 300 × 300, cropped
7131d4ec-e520-4129-b964-05a803fde311-preview.webp 1200 wide
The long name is a random uuid, so two files called photo.jpg never overwrite each other; the original name is kept in the database. Look at the row in tinker:
$photo = App\Models\Product::find(1)->firstUpload('photos');
$photo->name; // "lamp.jpg", the original file name
$photo->collection; // "photos"
$photo->conversions; // ["thumb" => "products/1/photos/...-thumb.webp", "preview" => "products/1/photos/...-preview.webp"]
$photo->meta('caption'); // the caption, or null when you left it empty
thumb and preview are the two sizes the package makes by default. Add your own in config/uploads.php, see Conversions & PDF pages.
Step 8. Make the thumbnails in the background
On a real site you do not want a visitor to wait while a large photo is resized. Remove the line UPLOADS_QUEUE_CONNECTION=sync from .env again. The package now uses the queue of your application (in a new Laravel application that is database: the work is stored in the jobs table). If php artisan serve is running, it restarts by itself when .env changes.
Upload another photo. The page shows the photo itself instead of a thumbnail: the thumbnail does not exist yet, and thumbnailUrl() falls back to the original so the page never shows a broken image.
Now start a worker, the process that does the work from the queue, in a second terminal:
php artisan queue:work
The worker prints Darvis\Uploads\Jobs\ProcessUpload ... DONE. Reload the page: the thumbnail is there. On your server the worker has to run all the time; that is a setting of your hosting, see Laravel’s queue documentation.
Before you put this online
This tutorial page has no login: anyone who knows the address can upload and remove photos. In a real application put the routes behind the auth middleware, or check in the controller who may change the product:
Route::middleware('auth')->group(function () {
// the three routes from step 3
});
Where to go next
- How it works: the ideas behind collections, disks, conversions, the queue and signed links.
- Private files: files only logged in users may open, such as invoices or print files.
- Livewire components: a ready made upload manager with captions, drag and drop sorting and remove buttons, instead of the form you built here.
- Conversions & PDF pages: your own sizes, and a PDF turned into one image per page.
- Troubleshooting: when something does not work, look up the message there.