Lightweight Laravel - deconstructing a full stack framework
Published by Matthew Daly at 30th December 2020 5:00 pm
Back when I used to work with Django, I read the book Lightweight Django, and it completely changed the way I thought about building web applications. For years I'd heard the same lines parroted about how Django was too large and bloated, and something like Flask was a better bet for many applications, and this book completely blew this misconception away. By demonstrating how it was possible to break the framework apart, use just what you need, and leave out what you don't, it showed how I could benefit from my familiarity with Django, while making it more suitable for smaller applications.
Laravel, like Django, is a full stack framework, and is often subject to similar misconceptions about bloat. But just because the framework ships with all this stuff, doesn't mean you're obliged to use it all. If you know you aren't going to need all of a framework's functionality, there's nothing stopping you getting rid of what you don't need, or even replacing it with something else. In this article, I'll show you how to apply the same methodology to a Laravel application to remove what you don't need. As part of this, we'll be building a simple placeholder image service. This was used in Lightweight Django as it's a good example of an application that is completely stateless, and doesn't need sessions or a database, so it's often seen as a bad fit for a full stack framework. Since the same applies here, it's a good example for us too.
Getting started
Run the following command in the shell to create a new Laravel application:
$ composer create-project --prefer-dist laravel/laravel lightweight-laravel
What this actually does is as follows:
- Resolve the latest release of the package
laravel/laravel
that will work on your system - Copy it from the repository to the specified location
- Carry out any post-install scripts specified, such as creating the
.env
file and generating a key
However, that's just a standardised boilerplate for Laravel applications. Most of the functionality of the framework is in the package laravel/framework
, which is included as a dependency in your composer.json
. This makes sense, because by keeping as much of the actual framework out of the starter boilerplate and in a separate repository, it minimises the work required to update the application to a new version. It also means you can strip that boilerplate down to remove references to things you don't need, and even create your own custom boilerplates to save you work in future.
Stripping down the boilerplate
Let's start stripping out the things we don't need. Since our application is stateless, we have no need whatsoever of a database, so we can delete the app/Models
and database
folders. We'll want to support Redis for the cache, so we can't delete the file config/database.php
, but we can remove any references to the database other than Redis from that file. We can delete some other files from the config/
folder, namely auth.php
, broadcasting.php
, filesystems.php
, mail.php
, queue.php
, services.php
and session.php
.
We also don't need a lot of the middleware that ships with Laravel. If you go into the file app/Http/Kernel.php
you'll see that it assigns some middleware as global, some to the web
and api
groups, and some as optional route middleware. In this file:
- We don't need to make any POST requests to this application, so we can lose the
ValidatePostSize
middleware from the global middleware entirely - The
web
group relates to cookies, sessions, CSRF, authentication and handling routing with substitute bindings. Since we don't need any of that we can empty this group entirely - The
auth
,auth.basic
,can
,guest
,password.confirm
, andverified
route middleware is also surplus to requirements and can go
As this change is a bit fiddly, here's a patch, which may be easier to read:
1From 6bc87e9602e839d5635963b6d740279b2dbcf16b Mon Sep 17 00:00:00 20012From: Matthew Daly <Matthew Daly 450801+matthewbdaly@users.noreply.github.com>3Date: Wed, 30 Dec 2020 11:54:56 +00004Subject: [PATCH] Removed unwanted middleware56---7 app/Http/Kernel.php | 14 --------------8 1 file changed, 14 deletions(-)910diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php11index 30020a5..10e150d 10064412--- a/app/Http/Kernel.php13+++ b/app/Http/Kernel.php14@@ -18,7 +18,6 @@ class Kernel extends HttpKernel15 \App\Http\Middleware\TrustProxies::class,16 \Fruitcake\Cors\HandleCors::class,17 \App\Http\Middleware\PreventRequestsDuringMaintenance::class,18- \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,19 \App\Http\Middleware\TrimStrings::class,20 \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,21 ];22@@ -30,13 +29,6 @@ class Kernel extends HttpKernel23 */24 protected $middlewareGroups = [25 'web' => [26- \App\Http\Middleware\EncryptCookies::class,27- \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,28- \Illuminate\Session\Middleware\StartSession::class,29- // \Illuminate\Session\Middleware\AuthenticateSession::class,30- \Illuminate\View\Middleware\ShareErrorsFromSession::class,31- \App\Http\Middleware\VerifyCsrfToken::class,32- \Illuminate\Routing\Middleware\SubstituteBindings::class,33 ],3435 'api' => [36@@ -53,14 +45,8 @@ class Kernel extends HttpKernel37 * @var array38 */39 protected $routeMiddleware = [40- 'auth' => \App\Http\Middleware\Authenticate::class,41- 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,42 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,43- 'can' => \Illuminate\Auth\Middleware\Authorize::class,44- 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,45- 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,46 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,47 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,48- 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,49 ];50 }51--522.28.053
These changes also mean a lot of the service providers and facades are now redundant and can be removed from the application. If you go into config/app.php
you can remove AuthServiceProvider
, BroadcastServiceProvider
, CookieServiceProvider
, MailServiceProvider
, NotificationServiceProvider
, PaginationServiceProvider
, PasswordResetServiceProvider
, SessionServiceProvider
and TranslationServiceProvider
from the providers section, as well as the commented-out local BroadcastServiceProvider
. You can also delete the facades for Auth
, Cookie
, DB
, Eloquent
, Gate
, Lang
, Mail
, Notification
, Password
, Queue
, Schema
, Session
, and Storage
.
Again, here's a patch of the required changes:
1From 66be3b836706ef488b890cdae6e97d4fc6195dd6 Mon Sep 17 00:00:00 20012From: Matthew Daly <Matthew Daly 450801+matthewbdaly@users.noreply.github.com>3Date: Wed, 30 Dec 2020 12:10:25 +00004Subject: [PATCH] Removed unused service providers and facades56---7 config/app.php | 26 --------------------------8 1 file changed, 26 deletions(-)910diff --git a/config/app.php b/config/app.php11index 2a2f0eb..b7a38c8 10064412--- a/config/app.php13+++ b/config/app.php14@@ -139,26 +139,17 @@ return [15 /*16 * Laravel Framework Service Providers...17 */18- Illuminate\Auth\AuthServiceProvider::class,19- Illuminate\Broadcasting\BroadcastServiceProvider::class,20 Illuminate\Bus\BusServiceProvider::class,21 Illuminate\Cache\CacheServiceProvider::class,22 Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,23- Illuminate\Cookie\CookieServiceProvider::class,24 Illuminate\Database\DatabaseServiceProvider::class,25 Illuminate\Encryption\EncryptionServiceProvider::class,26 Illuminate\Filesystem\FilesystemServiceProvider::class,27 Illuminate\Foundation\Providers\FoundationServiceProvider::class,28 Illuminate\Hashing\HashServiceProvider::class,29- Illuminate\Mail\MailServiceProvider::class,30- Illuminate\Notifications\NotificationServiceProvider::class,31- Illuminate\Pagination\PaginationServiceProvider::class,32 Illuminate\Pipeline\PipelineServiceProvider::class,33 Illuminate\Queue\QueueServiceProvider::class,34 Illuminate\Redis\RedisServiceProvider::class,35- Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,36- Illuminate\Session\SessionServiceProvider::class,37- Illuminate\Translation\TranslationServiceProvider::class,38 Illuminate\Validation\ValidationServiceProvider::class,39 Illuminate\View\ViewServiceProvider::class,4041@@ -170,9 +161,6 @@ return [42 * Application Service Providers...43 */44 App\Providers\AppServiceProvider::class,45- App\Providers\AuthServiceProvider::class,46- // App\Providers\BroadcastServiceProvider::class,47- App\Providers\EventServiceProvider::class,48 App\Providers\RouteServiceProvider::class,4950 ],51@@ -193,35 +181,21 @@ return [52 'App' => Illuminate\Support\Facades\App::class,53 'Arr' => Illuminate\Support\Arr::class,54 'Artisan' => Illuminate\Support\Facades\Artisan::class,55- 'Auth' => Illuminate\Support\Facades\Auth::class,56 'Blade' => Illuminate\Support\Facades\Blade::class,57 'Broadcast' => Illuminate\Support\Facades\Broadcast::class,58 'Bus' => Illuminate\Support\Facades\Bus::class,59 'Cache' => Illuminate\Support\Facades\Cache::class,60 'Config' => Illuminate\Support\Facades\Config::class,61- 'Cookie' => Illuminate\Support\Facades\Cookie::class,62 'Crypt' => Illuminate\Support\Facades\Crypt::class,63- 'DB' => Illuminate\Support\Facades\DB::class,64- 'Eloquent' => Illuminate\Database\Eloquent\Model::class,65- 'Event' => Illuminate\Support\Facades\Event::class,66 'File' => Illuminate\Support\Facades\File::class,67- 'Gate' => Illuminate\Support\Facades\Gate::class,68 'Hash' => Illuminate\Support\Facades\Hash::class,69 'Http' => Illuminate\Support\Facades\Http::class,70- 'Lang' => Illuminate\Support\Facades\Lang::class,71 'Log' => Illuminate\Support\Facades\Log::class,72- 'Mail' => Illuminate\Support\Facades\Mail::class,73- 'Notification' => Illuminate\Support\Facades\Notification::class,74- 'Password' => Illuminate\Support\Facades\Password::class,75- 'Queue' => Illuminate\Support\Facades\Queue::class,76 'Redirect' => Illuminate\Support\Facades\Redirect::class,77 // 'Redis' => Illuminate\Support\Facades\Redis::class,78 'Request' => Illuminate\Support\Facades\Request::class,79 'Response' => Illuminate\Support\Facades\Response::class,80 'Route' => Illuminate\Support\Facades\Route::class,81- 'Schema' => Illuminate\Support\Facades\Schema::class,82- 'Session' => Illuminate\Support\Facades\Session::class,83- 'Storage' => Illuminate\Support\Facades\Storage::class,84 'Str' => Illuminate\Support\Str::class,85 'URL' => Illuminate\Support\Facades\URL::class,86 'Validator' => Illuminate\Support\Facades\Validator::class,87--882.28.089
There are a few service providers that ideally we'd strip out but are tightly integrated into the framework. For instance, the database and queue service providers are both used by some Artisan commands, and it's not very practical to disable only those commands, so removing them will stop Artisan from working. If you don't mind running the development server manually, you can go ahead and remove these.
Building the application
Now, let's set out how our application will work. We will have two routes:
- A route that accepts width and height parameters in the route itself, and responds with a PNG response sized accordingly
- A route that returns a simple HTML homepage
You've no doubt seen various novelty placeholder sites like placekitten.com for use in web projects, and this will be similar to that. We'll use a simple black image with the dimensions in white text, but you should be able to use this as the basis of a more sophisticated placeholder service, such as if you wanted to use branded images for a particular client.
Since the home page will be fairly straightforward, let's do that first. Delete the existing resources/views/welcome.blade.php
file and save this to resources/views/home.blade.php
:
resources/views/home.blade.php1<!DOCTYPE html>2<html lang="en">3<head>4 <meta charset="utf-8">5 <title>Laravel Placeholder Images</title>6 <link href="{{ mix('css/app.css') }}" rel="stylesheet">7</head>8<body>9 <h1>Laravel Placeholder Images</h1>10 <p>This server can be used for serving placeholder11 images for any web page.</p>12 <p>To request a placeholder image of a given width and height13 simply include an image with the source pointing to14 <b>/image/<width>x<height>/</b>15 on this server such as:</p>16 <pre>17 <img src="{{ $example }}" >18 </pre>19 <h2>Examples</h2>20 <ul>21 <li><img src="{{{ route('placeholder', ['width' => 50, 'height' => 50]) }}}"></li>22 <li><img src="{{{ route('placeholder', ['width' => 100, 'height' => 50]) }}}"></li>23 <li><img src="{{{ route('placeholder', ['width' => 50, 'height' => 100]) }}}"></li>24 </ul>25</body>26</html>
Note we're using the route()
helper to add some example images, even though it's not in place yet. Add this route to your routes/web.php
as well:
routes/web.php1Route::get('/', function () {2 return view('home', [3 'example' => route('placeholder', ['width' => 50, 'height' => 50]),4 ]);5});
Again, note that we're using the route()
helper to get the URL for the placeholder image. Next, we need to create the outline of the route for getting the placeholders:
routes/web.php1Route::get('/placeholder/{width}x{height}', function (int $width, int $height) {2})->where(['width' => '[0-9]+', 'height' => '[0-9]+'])3 ->name('placeholder');
Due to the limited scope of this application, we won't bother with full controllers, but you can add them if you wish. Note we've specified the name placeholder
and set a regex to validate the width
and height
parameters.
Now let's populate the callback to generate a PNG file.
routes/web.php1Route::get('/placeholder/{width}x{height}', function (int $width, int $height) {2 if (!$img = imagecreatetruecolor($width, $height)) {3 abort();4 }5 $textColour = imagecolorallocate($img, 255, 255, 255);6 imagestring($img, 1, 5, 5, "$width X $height", $textColour);7 ob_start();8 imagepng($img);9 $file = ob_get_contents();10 ob_end_clean();11 return response()->make($file, 200, [12 'Content-type' => 'image/png'13 ]);14})->where(['width' => '[0-9]+', 'height' => '[0-9]+'])15 ->name('placeholder');
We'll also add some very basic CSS to the provided CSS file:
resources/css/app.css1body {2 text-align: center;3}45ul {6 list-type: none;7}89li {10 display: inline-block;11}
Don't forget to build this with npm install && npm run production
too.
If you now run php artisan serve
you should be able to see that it works - the homepage renders, and the embedded images are pulled in OK. However, there are three potential issues:
- The images themselves are regenerated each time. Since they never change, it's a no-brainer to cache them indefinitely for the best performance, and if we do need to change them in the future we can just flush the cache to resolve this
- Similarly, we should use ETags to allow the application to tell the browser when the image has changed
- There's no limit on how large images can be, so a malicious user could request a huge image to break the system
Let's tackle these in order. First, let's create some middleware to handle the caching:
app/Http/Middleware/CacheImages.php1<?php23namespace App\Http\Middleware;45use Closure;6use Illuminate\Http\Request;7use Illuminate\Support\Facades\Cache;89final class CacheImages10{11 /**12 * Handle an incoming request.13 *14 * @param \Illuminate\Http\Request $request15 * @param \Closure $next16 * @return mixed17 */18 public function handle(Request $request, Closure $next)19 {20 $key = sprintf("%d.%d", $request->width, $request->height);21 return Cache::rememberForever($key, function () use ($next, $request) {22 return $next($request);23 });24 }25}
We construct a cache key from the request width and height, and use the Cache::rememberForever()
method to cache the response. We then register this middleware as route middleware in app/Http/Kernel.php
:
app/Http/Kernel.php1 protected $routeMiddleware = [2 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,3 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,4 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,5 'cache.images' => \App\Http\Middleware\CacheImages::class,6 ];
And apply it to the image route:
routes/web.php1Route::get('/placeholder/{width}x{height}', function (int $width, int $height) {2 if (!$img = imagecreatetruecolor($width, $height)) {3 abort();4 }5 $textColour = imagecolorallocate($img, 255, 255, 255);6 imagestring($img, 1, 5, 5, "$width X $height", $textColour);7 ob_start();8 imagepng($img);9 $file = ob_get_contents();10 ob_end_clean();11 return response()->make($file, 200, [12 'Content-type' => 'image/png'13 ]);14})->where(['width' => '[0-9]+', 'height' => '[0-9]+'])15 ->name('placeholder')16 ->middleware('cache.images');
Next, let's set ETags on our images. Laravel comes with the cache.headers
middleware, which we can easily wrap around our placeholder route:
routes/web.php1Route::middleware('cache.headers:public;etag')->group(function () {2 Route::get('/placeholder/{width}x{height}', function (int $width, int $height) {3 if (!$img = imagecreatetruecolor($width, $height)) {4 abort();5 }6 $textColour = imagecolorallocate($img, 255, 255, 255);7 imagestring($img, 1, 5, 5, "$width X $height", $textColour);8 ob_start();9 imagepng($img);10 $file = ob_get_contents();11 ob_end_clean();12 return response()->make($file, 200, [13 'Content-type' => 'image/png'14 ]);15 })->where(['width' => '[0-9]+', 'height' => '[0-9]+'])16 ->name('placeholder')17 ->middleware('cache.images');18});
Finally, let's handle the dimensions issue. Again, this is something that is probably best handled in middleware since that way it can be rejected before the point it gets to the route handler. All we need to do is to check to see if the width and height parameters exceed the intended value, and throw an error in the middleware:
app/Http/Middleware/ValidateImageDimensions.php1<?php23namespace App\Http\Middleware;45use Closure;6use Illuminate\Http\Request;7use Illuminate\Validation\ValidationException;89final class ValidateImageDimensions10{11 /**12 * Handle an incoming request.13 *14 * @param \Illuminate\Http\Request $request15 * @param \Closure $next16 * @return mixed17 */18 public function handle(Request $request, Closure $next)19 {20 if ($request->width > 2000 || $request->height > 2000) {21 abort(422, 'Height and width cannot exceed 2000 pixels');22 }23 return $next($request);24 }25}
Register this middleware in app/Http/Kernel.php
:
app/Http/Kernel.php1 protected $routeMiddleware = [2 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,3 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,4 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,5 'cache.images' => \App\Http\Middleware\CacheImages::class,6 'validate.images' => \App\Http\Middleware\ValidateImageDimensions::class,7 ];
And apply it to the image route:
routes/web.php1Route::middleware('cache.headers:public;etag')->group(function () {2 Route::get('/placeholder/{width}x{height}', function (int $width, int $height) {3 if (!$img = imagecreatetruecolor($width, $height)) {4 abort();5 }6 $textColour = imagecolorallocate($img, 255, 255, 255);7 imagestring($img, 1, 5, 5, "$width X $height", $textColour);8 ob_start();9 imagepng($img);10 $file = ob_get_contents();11 ob_end_clean();12 return response()->make($file, 200, [13 'Content-type' => 'image/png'14 ]);15 })->where(['width' => '[0-9]+', 'height' => '[0-9]+'])16 ->name('placeholder')17 ->middleware(['validate.images', 'cache.images']);18});
And we're done! We now have a basic, but functional, stateless Laravel application that's been stripped of a lot of the unnecessary functionality. There are a few further changes that could be made to expand this if necessary, such as:
- Amend the project to allow requesting different image formats using an additional route parameter (hint - you'll want to use something like Intervention for this)
- Serve different images, either by using one as a starting template so they are all branded the same, or specifying one from several options in the URL, such as with PlaceCage
However, I will leave these as an exercise for the reader. The code for this project is available on Github if you get stuck at any point.
Hopefully, this article has given you some food for thought about how you can use Laravel for applications you might have previously considered too small to use it for. Don't worry too much about removing something that you need to add later - version control means you can always retrieve it if it turns out you do need it later. I'd also add that potentially the same approach can be applied to other full stack PHP frameworks, though you'll have to do some exploring on your own to determine this.