Laravel: Upload File and Hide Real URL for Secure Download under UUID

File uploads are one of the essential things in web apps these days. But secure download of these files is sometimes even more important. So how to store files securely so people wouldn't have access to them or guess their URLs or IDs of their records? Here's a small demo tutorial.

What we're building here

A small list of books with their covers, looks like this:

Looks simple, right? Now, let's add some conditions:

  • Original cover filename should be visible but file can't be accessed in public directly
  • You can download book cover with URL with book ID as a parameter, but that ID cannot be "guessable"

Preparation: structure

We will deal with only one DB table.
This is how our database migration looks like:

public function up()
{
    Schema::create('books', function (Blueprint $table) {
        $table->increments('id');
        $table->uuid('uuid')->nullable();
        $table->string('title');
        $table->string('cover')->nullable();
        $table->timestamps();
    });
}

Field uuid will be used to hide original ID, and field cover will store the original filename.

Model app/Book.php is really simple - only fillable fields:

class Book extends Model
{
    protected $fillable = ['uuid', 'title', 'cover'];
}

Now, we need two routes in routes/web.php:

Route::resource('books', 'BookController');
Route::get('books/{uuid}/download', 'BookController@download')->name('books.download');

So we manage books with resourceful BookController and will have a separate route for download.
The first part of controller looks simple - list and create form:

use App\Book;

class BookController extends Controller
{
    public function index()
    {
        $books = Book::all();
        return view('books.index', compact('books'));
    }

    public function create()
    {
        return view('books.create');
    }
}

This is how our views look like - just a simple table and form.

resources/views/books/index.blade.php:

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Books list</div>

                <div class="card-body">

                    <a href="{{ route('books.create') }}" class="btn btn-primary">Add new book</a>
                    <br /><br />

                    <table class="table">
                        <tr>
                            <th>Title</th>
                            <th>Download file</th>
                        </tr>
                        @forelse ($books as $book)
                            <tr>
                                <td>{{ $book->title }}</td>
                                <td><a href="{{ route('books.download', $book->uuid) }}">{{ $book->cover }}</a></td>
                            </tr>
                        @empty
                            <tr>
                                <td colspan="2">No books found.</td>
                            </tr>
                        @endforelse
                    </table>

                </div>
            </div>
        </div>
    </div>
</div>
@endsection

And resources/views/books/create.blade.php:

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Add new book</div>

                <div class="card-body">

                    <form action="{{ route('books.store') }}" method="POST" enctype="multipart/form-data">
                        @csrf

                        Title:
                        <br>
                        <input type="text" name="title" class="form-control">

                        <br>

                        Cover File:
                        <br>
                        <input type="file" name="cover">

                        <br><br>

                        <input type="submit" value=" Upload book " class="btn btn-primary">

                    </form>

                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Notice: our main resources/views/layouts/app.blade.php file is generated with php artisan make:auth command, we're just reusing the same structure as default Laravel login/register pages.

This is how simple is the upload form:


Uploading the file

I have written a big big guide about uploading files in Laravel, but for this tutorial you need to know only a few things:

1. Upload form should contain <form enctype="multipart/form-data"> - I'm shocked how many people forget this.

2. Upload app/Http/BookController.php code will look like this:

use Webpatser\Uuid\Uuid;

public function store(Request $request)
{
    $book = $request->all();
    $book['uuid'] = (string)Uuid::generate();
    if ($request->hasFile('cover')) {
        $book['cover'] = $request->cover->getClientOriginalName();
        $request->cover->storeAs('books', $book['cover']);
    }
    Book::create($book);
    return redirect()->route('books.index');
}

What we're doing here:

  • We build the array $book which then is passed to Book::create() method;
  • We generate a unique UUID for the book with the help of webpatser/laravel-uuid package;
  • We check if there is a file, if so - we upload it to a storage/app/books folder with original filename (actual folder may depend on your config/filesystems.php settings);
  • Finally we redirect back to the list.

As a result, here's what file will be uploaded and in which folder:

And here's our database entry:

By default, file is stored in storage/app folder and not accessible to the public (unless you choose public driver), so that's good - we achieve our first goal of securing the file.

But the second part - how to download the file without ability to guess its filename or ID?


Downloading File by its UUID

We already have this route:

Route::get('books/{uuid}/download', 'BookController@download')->name('books.download');

In our Blade file, we refer to this route with this syntax:

<a href="{{ route('books.download', $book->uuid) }}">{{ $book->cover }}</a>

So, this is how the actual URL looks like:

Finally, this is how download method looks like in app/Http/Controllers/BookController.php:

public function download($uuid)
{
    $book = Book::where('uuid', $uuid)->firstOrFail();
    $pathToFile = storage_path('app/books/' . $book->cover);
    return response()->download($pathToFile);
}

That's it, users can download the file with its original filename, but without knowing where it is stored on the server, or book ID.

Hope that was helpful!

No comments or questions yet...

Like our articles?

Become a Premium Member for $129/year or $29/month
What else you will get:
  • 58 courses (1056 lessons, total 44 h 09 min)
  • 78 long-form tutorials (one new every week)
  • access to project repositories
  • access to private Discord

Recent Premium Tutorials