Lightweight Laravel - deconstructing a full stack framework

Published by 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, and verified 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 2001
2From: Matthew Daly <Matthew Daly 450801+matthewbdaly@users.noreply.github.com>
3Date: Wed, 30 Dec 2020 11:54:56 +0000
4Subject: [PATCH] Removed unwanted middleware
5
6---
7 app/Http/Kernel.php | 14 --------------
8 1 file changed, 14 deletions(-)
9
10diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
11index 30020a5..10e150d 100644
12--- a/app/Http/Kernel.php
13+++ b/app/Http/Kernel.php
14@@ -18,7 +18,6 @@ class Kernel extends HttpKernel
15 \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 HttpKernel
23 */
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 ],
34
35 'api' => [
36@@ -53,14 +45,8 @@ class Kernel extends HttpKernel
37 * @var array
38 */
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.0
53

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 2001
2From: Matthew Daly <Matthew Daly 450801+matthewbdaly@users.noreply.github.com>
3Date: Wed, 30 Dec 2020 12:10:25 +0000
4Subject: [PATCH] Removed unused service providers and facades
5
6---
7 config/app.php | 26 --------------------------
8 1 file changed, 26 deletions(-)
9
10diff --git a/config/app.php b/config/app.php
11index 2a2f0eb..b7a38c8 100644
12--- a/config/app.php
13+++ b/config/app.php
14@@ -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,
40
41@@ -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,
49
50 ],
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.0
89

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.php
1<!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 placeholder
11 images for any web page.</p>
12 <p>To request a placeholder image of a given width and height
13 simply include an image with the source pointing to
14 <b>/image/&lt;width&gt;x&lt;height&gt;/</b>
15 on this server such as:</p>
16 <pre>
17 &lt;img src="{{ $example }}" &gt;
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.php
1Route::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.php
1Route::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.php
1Route::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.css
1body {
2 text-align: center;
3}
4
5ul {
6 list-type: none;
7}
8
9li {
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.php
1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6use Illuminate\Http\Request;
7use Illuminate\Support\Facades\Cache;
8
9final class CacheImages
10{
11 /**
12 * Handle an incoming request.
13 *
14 * @param \Illuminate\Http\Request $request
15 * @param \Closure $next
16 * @return mixed
17 */
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.php
1 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.php
1Route::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.php
1Route::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.php
1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6use Illuminate\Http\Request;
7use Illuminate\Validation\ValidationException;
8
9final class ValidateImageDimensions
10{
11 /**
12 * Handle an incoming request.
13 *
14 * @param \Illuminate\Http\Request $request
15 * @param \Closure $next
16 * @return mixed
17 */
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.php
1 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.php
1Route::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.