Optimized image uploads with CKEditor and Laravel

By Priyash Patil | Updated: Nov 04, 2023 12PM IST

Optimized image uploads with CKEditor and Laravel

In today's digital age, images are an integral part of web content. They not only enhance the visual appeal of a website but also play a vital role in conveying information effectively. However, the larger the image files, the slower your website may load, leading to a poor user experience. This is where image resizing and optimization come into play. In this article, we'll explore how to implement image resizing and optimization in Laravel with CKEditor 5, while harnessing the power of the Intervention/Image package.

The Importance of Image Optimization

Before diving into the technical aspects, it's essential to understand why image optimization matters. When web images are too large, they consume more bandwidth and take longer to load, resulting in slower website performance. This, in turn, affects your website's search engine ranking and user engagement.

Image optimization is reducing image file sizes while maintaining reasonable image quality. It involves compressing and resizing images, which can significantly improve your website's load time and overall performance.

What is CKEditor?

CKEditor is a powerful WYSIWYG (What You See Is What You Get) text editor that allows users to format and style their content easily. It is a popular choice among developers when it comes to providing a user-friendly interface for content creation. In this context, CKEditor 5 can be integrated into your Laravel application, enabling users to upload and manage images effortlessly.

Laravel and the Intervention Image Package

Laravel is a PHP web application framework known for its elegant syntax and developer-friendly features. To handle image resizing and optimization, we'll rely on the Intervention/Image package, which is a popular image-handling library for PHP. It provides various functionalities to manipulate and optimize images, making it a valuable tool for developers.

Set up a fresh Laravel 10 project and a simple blog

First of all, we need to get a fresh Laravel version application using the bellow command, open your terminal OR command prompt and run the bellow command:

composer create-project laravel/laravel example-app

After creating a fresh project make sure to update the database credentials in  .env. Though configuring CKEditor doesn’t require a Database, to test the entire flow we’ll set up a minimal blog application.

The blog post model, controller and views

To ensure everything works we’ll set up an actual blog post model and controller which will have to create, edit and update endpoints. So let’s create a Model, Migration and Controller by running the following command.

 php artisan make:model BlogPost -mfc

The flag -mfc stands for creating a migration, factory and controller. This will create four files in the project. Do note the migration file name in your system will be different because migrations are named based on the current timestamp.

  1. app/Models/BlogPost.php
  2. database/migrations/2023_10_29_125434_create_blog_posts_table.php
  3. database/factories/BlogPostFactory.php
  4. app/Http/Controllers/BlogPostController.php

Specify database schema

Now open the migration file and update as following code which will add the blog title and blog body columns on blogs_posts table.

<?php

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

return new class extends Migration {
   /**
    * Run the migrations.
    */
   public function up(): void
   {
       Schema::create('blog_posts', function (Blueprint $table) {
           $table->id();
           $table->string('title');
           $table->text('body');
           $table->timestamps();
       });
   }

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

After updating the migration file run the following command to create the table with the specified columns in the database. 

php artisan migrate

Once the migrations are completed update the blog post model with fillable fields to avoid Mass Assignment Exception while storing or updating the blog post in the database. Update the app/Models/BlogPost.php as per the below.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class BlogPost extends Model
{
   use HasFactory;

   protected $fillable = ['title', 'body'];
}

And then update database/factories/BlogPostFactory.php with faker statements.

<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BlogPost>
*/
class BlogPostFactory extends Factory
{
   /**
    * Define the model's default state.
    *
    * @return array<string, mixed>
    */
   public function definition(): array
   {
       return [
           'title' => $this->faker->sentence,
           'body' => $this->faker->paragraph,
       ];
   }
}

To seed the database update the database/seeders/DatabaseSeeder.php as below:

<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use App\Models\BlogPost;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
   /**
    * Seed the application's database.
    */
   public function run(): void
   {
       // \App\Models\User::factory(10)->create();

       // \App\Models\User::factory()->create([
       //     'name' => 'Test User',
       //     'email' => '[email protected]',
       // ]);

       BlogPost::factory(10)->create();
   }
}

Then run the database seed command to seed the database with 10 Blog Posts. You can change the count of records as you want.

php artisan db:seed

Setup the controller for editor endpoints

Now the database is configured let’s create the controller methods and set endpoints for the editor. First update the app/Http/Controllers/BlogPostController.php as the following:

<?php
namespace App\Http\Controllers;
use App\Models\BlogPost;
use Illuminate\Http\Request;

class BlogPostController extends Controller
{
   public function index()
   {
       $blogPosts = BlogPost::paginate();
       return view('blog-posts.index', compact('blogPosts'));
   }

   public function edit(BlogPost $blogPost)
   {
       return view('blog-posts.edit', compact('blogPost'));
   }

   public function update(Request $request, BlogPost $blogPost)
   {
       $validated = $request->validate([
           'title' => 'required|string|max:255',
           'body' => 'required|string',
       ]);

       $blogPost->update($validated);

       return redirect()->back();
   }
}

In the controller, we have three methods index, edit and update

Now, let’s link these methods to routes by updating the routes/web.php with the following endpoints:

<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
   return view('welcome');
});
Route::get('/blog-posts', [\App\Http\Controllers\BlogPostController::class, 'index'])->name('blog-posts.index');
Route::get('/blog-posts/{blogPost}', [\App\Http\Controllers\BlogPostController::class, 'edit'])->name('blog-posts.edit');
Route::put('/blog-posts/{blogPost}', [\App\Http\Controllers\BlogPostController::class, 'update']);

Do note that the last endpoint doesn’t have the name specified. When we do not specify the action on a form the browser will use the same URL as the current open URL. Since we are using the same URLs but different HTTP methods, Laravel will automatically handle the requests.

Add views and layouts

To keep views clean and simple we’ll use a Bootstrap template. Create three view files for index, edit and the admin on the following paths:

  1. resources/views/layouts/admin.blade.php
  2. resources/views/blog-posts/index.blade.php
  3. resources/views/blog-posts/edit.blade.php

Update the resources/views/layouts/admin.blade.php with the following Bootstrap starter template:

<!doctype html>
<html lang="en">
<head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <title>CKEditor demo</title>
   <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
         integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
   @stack('styles')
</head>
<body>
@yield('content')

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
       integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
       crossorigin="anonymous"></script>
@stack('scripts')
</body>
</html>

In this layout, we use an HTML structure with content as yield and the styles and scripts stacks to allow pushing javascript and CSS in required views.

Now update the resources/views/blog-posts/index.blade.php with the following code:

@extends('layouts.admin')

@section('content')
   <div class="container py-3" style="max-width: 600px">
       <h1 class="mb-3">Blog Index</h1>

       <ul>
           @foreach($blogPosts as $blogPost)
               <li><a href="{{route('blog-posts.edit', $blogPost)}}">{{$blogPost->title}}</a></li>
           @endforeach
       </ul>

       {{$blogPosts->links()}}
   </div>
@endsection

The index view extends the admin layout and usages the data passed by the controller to render the blog list with links to the edit page and the pagination links. 

Laravel by default uses Tailwind CSS for pagination. To change this default behaviour update the app/Providers/AppServiceProvider.php to specify the paginator view as follows:

<?php

namespace App\Providers;

use Illuminate\Pagination\Paginator;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
   /**
    * Register any application services.
    */
   public function register(): void
   {
       //
   }

   /**
    * Bootstrap any application services.
    */
   public function boot(): void
   {
       Paginator::useBootstrapFive();
   }
}

Lastly, update resources/views/blog-posts/edit.blade.php with editor form.

@extends('layouts.admin')

@section('content')
   <div class="container py-3" style="max-width: 600px">
       <h1 class="mb-3">Edit Blog Post</h1>
       <p>Last updated: {{$blogPost->updated_at->format('d-m-Y H:i:s')}}</p>

       <form method="post">
           @method('PUT')
           @csrf

           <div class="mb-3">
               <label for="title"></label>
               <input type="text" class="form-control @error('title') is-invalid @enderror" id="title" name="title"
                      required value="{{old('title', $blogPost->title)}}">
               @error('title')
               <div class="invalid-feedback">{{$message}}</div>
               @enderror
           </div>

           <div class="mb-3">
               <label for="body"></label>
               <textarea class="form-control @error('body') is-invalid @enderror" rows="5" id="body" name="body"
                         required>{{old('body', $blogPost->body)}}</textarea>
               @error('body')
               <div class="invalid-feedback">{{$message}}</div>
               @enderror
           </div>

           <button class="btn btn-primary" type="submit">Update</button>
       </form>
   </div>
@endsection

The edit view starts with a heading and is the last updated_at time. Then a form with the title as text input and body as textarea input. Also, the form has the POST method but uses Laravel’s method directive specifying it as a `PUT` method. Then the CSRF token and the fields with validation error display related tags. 

Note that we are not specifying the form action here, As explained earlier the bowers will fall back to the current URL to submit the form which is how we have set up the routes.

Install CKEditor 5

There are many ways to install CK Editor, using CDN, npm, prebuilt zip package and CKEditors online builder. To keep things simple we’ll start with the CDN method.

Update the resources/views/blog-posts/edit.blade.php and the following code below the section closing directive.

@push('scripts')
   <script src="https://cdn.ckeditor.com/ckeditor5/40.0.0/classic/ckeditor.js"></script>
   <script type="text/javascript">
       ClassicEditor
           .create(document.querySelector('#body'))
           .catch(error => {
               console.error(error);
           });
   </script>
@endpush

In the above code, we are using @stack directive defined in resources/views/layouts/admin.blade.php and using the @push directive to push the scripts into the final HTML page before ending the body tag.

The first script loads the CKEditor from their CDN and the second script initiates the ClassicEditor on-to element with the id #body. In our case, the #body is declared on the textarea field which contains the blog body. Now if you refresh the page you should see the CKEditor in place of the Textarea field.

textarea vs editor.setData()

Right now we have configured  Classic Editor from CKEditor’s editors. As long as you are going to keep the editor simple and do not need additional plugins for the editor, The textarea method should work without any issues. The advantage of the Classic Editor is its automatic integration with the HTML forms. So for example, if you just make any change to the content using the editor and update the blog post it should work fine without any issues.

However, if you need additional features of CKEditor you might use the SuperBuild which almost contains all the official CKEditor plugins. But this or any customised editor build does not work well with textarea. For example, if your build contains the code plugin then it’s most likely to fail to render the HTML coming from the Database by default. To avoid such issues you would either have to encode the editor's content manually or you can use the editor.setData() to initialise the editor with rich data. 

Configuring the editor with  SuperBuild is out of the scope of this blog post so you can read more about it in the CKEditors docs.

CKEditor Image File Upload Protocol

If you try to upload an image right now with the editor, after selecting you should see the image is not added to the content. This is because we haven’t configured the image upload yet. CKEditor supports the following image upload adapters:

  1. Using file managers (CKBox file manager or CKFinder file manager)
  2. Easy Image integration
  3. Base64 image upload adapter
  4. Simple upload adapter

Both file managers and Easy Image are premium plugins and the Base 64 image upload adapter is might not efficient as it will store the images in the database as a base64 string. Which might cause the performance issues. So, we are going to configure the Image upload with the Simple upload adapter.

The Simple Upload adapter follows the communication protocol with the backend server in which when an image is selected the editor makes a XMLHttpRequest call with POST to the configured URL. Then the server must return the JSON response containing either the url or urls For example:

{
    "url": "https://example.com/images/foo.jpg"
}

Or if the server supports image compression and resizing then the response could be:

{
    "urls": {
        "default": "https://example.com/images/foo.jpg",
        "800": "https://example.com/images/foo-800.jpg",
        "1024": "https://example.com/images/foo-1024.jpg",
        "1920": "https://example.com/images/foo-1920.jpg"
    }
}

And If there are any errors then the server should return the response as:

{
    "error": {
        "message": "The image upload failed because the image was too big (max 1.5MB)."
    }
}

Why use Custom CKEditor build?

There’s one issue with the Classic CKEditor build. It is missing the file upload adapter. Because of this, the file upload won’t work. As we know there are many ways to create custom CKEditor build. To keep things simple we’ll use the CKEditor’s online builder. Follow the following steps to create the custom build using the online editor builder:

  1. Visit https://ckeditor.com/ckeditor-5/online-builder/
  2. Select Classic Editor.
  3. Remove the cloud services plugin from the selected options.
  4. Select the Simple Upload Adapter plugin.
  5. Keep the toolbar selection default.
  6. Select your preferred language.
  7. Finally, click the start button.

This will start building the custom editor and once done you can download the build zip file by clicking the download button. Upon downloading extract the zip and copy the files from the build folder to the application public/vendor/ckeditor folder.

Then update the script in  resources/views/blog-posts/edit.blade.php as follows:

--- a/resources/views/blog-posts/edit.blade.php
+++ b/resources/views/blog-posts/edit.blade.php
@@ -33,7 +33,7 @@
 @endsection

 @push('scripts')
-    <script src="https://cdn.ckeditor.com/ckeditor5/40.0.0/classic/ckeditor.js"></script>
+    <script src="{{asset('vendor/ckeditor/ckeditor.js')}}"></script>
     <script type="text/javascript">
         ClassicEditor
             .create(document.querySelector('#body'))

Now refresh the editor page. The editor should work fine.

Create The image upload endpoint

To configure the image upload with CKEditor we first need to create an endpoint to handle image upload and then return the image URL in the JSON response. Run the following command to create a controller that would handle the file upload.

php artisan make:controller EditorImageUploadController --invokable

This command will create an app/Http/Controllers/EditorImageUploadController.php with the __invoke method.

File upload and validation responses

Now that we have the controller update the controller with the following code in app/Http/Controllers/EditorImageUploadController.php.

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

class EditorImageUploadController extends Controller
{
   public function __invoke(Request $request)
   {
       $validator = Validator::make($request->all(), ['upload' => 'required|file|image|max:5000']);

       if ($validator->fails()) {
           return response()->json([
               'error' => [
                   'message' => $validator->errors()->first('upload')
               ]
           ], 400);
       }

       $path = $request->file('upload')->store('uploads', ['disk' => 'public']);
       $imageURL = Storage::disk('public')->url($path);

       return response()->json(['url' => $imageURL]);
   }
}

This code handles image uploads and follows these key steps:

  1. It uses the Laravel Validator to validate the incoming request. The validation checks if the upload field is required, is a file, is an image, and does not exceed 5000 kilobytes (5MB).
  2. If the validation fails, it returns a JSON response with an error message and a 400 (Bad Request) status code.
  3. If the validation passes, it stores the uploaded image in the 'public' disk under the 'uploads' directory and retrieves its URL.
  4. It returns a JSON response containing the URL of the uploaded image.

Since we are storing images on the public disk we must link the disk to the public folder. Run the following command:

php artisan storage:link

Now let’s define the upload endpoint by updating the web.php with the following line:

--- a/routes/web.php
+++ b/routes/web.php
@@ -9,3 +9,5 @@
 Route::get('/blog-posts', [\App\Http\Controllers\BlogPostController::class, 'index'])->name('blog-posts.index');
 Route::get('/blog-posts/{blogPost}', [\App\Http\Controllers\BlogPostController::class, 'edit'])->name('blog-posts.edit');
 Route::put('/blog-posts/{blogPost}', [\App\Http\Controllers\BlogPostController::class, 'update']);
+
+Route::post('/editor-uploads', \App\Http\Controllers\EditorImageUploadController::class);

The added route POST endpoint is editor-uploads which is mapped to EditorImageUploadController to handle the incoming image upload request. This being done let’s update the editor page to upload the files to this endpoint. Update the resources/views/blog-posts/edit.blade.php and add the below changes.

--- a/resources/views/blog-posts/edit.blade.php
+++ b/resources/views/blog-posts/edit.blade.php
@@ -39 +39,5 @@
-            .create(document.querySelector('#body'))
+            .create(document.querySelector('#body'), {
+                simpleUpload: {
+                    uploadUrl: '/editor-uploads',
+                }
+            })

In the above code, we are configuring the simpleUpload by specifying uploadUrl so it will automatically uploaded and updated to the completed URL.

Adding X-CSRF-TOKEN on file uploads

If you try to upload the file now it’ll show the error and the console log will show the error code 419. This is because Laravel has CSRF Protection enabled on all web endpoints. To solve this issue add the following meta tag to the resources/views/layouts/admin.blade.php inside the head tag:

<meta name="csrf-token" content="{{ csrf_token() }}">

This will add a meta csrf-token to the page. Then update the resources/views/blog-posts/edit.blade.php with simpleUpload config as below:

simpleUpload: {
   uploadUrl: '/editor-uploads',
   headers: {
       'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
   }
}

Using the headers option we are passing the X-CSRF-TOKEN parsed from the meta tag while uploading the file. After this change, the file upload should work fine and you should see the green check mark upon successful file upload.

Image Optimization

As the image uploads are working fine. Now we can focus on optimising the image size once uploaded. We must optimize the images as the uploaded images could be as big as 5 MB. The images with this large size will impact the site’s loading performance negatively. To address this we will encode the images with webp which is the standard for web images and generally results in small image sizes.

Intervention/Image Requirements and Installation

We'll rely on the Intervention/Image package, which is a popular image-handling library for PHP. It provides various functionalities to manipulate and optimize images, making it a valuable tool for developers. PHP Intervention package has official integration with Laravel. 

The intervention package requires Fileinfo Extension, (GD Library or Imagick PHP extension) installed to function properly. Ensure you have these extensions installed and then Install the package by running:

composer require intervention/image

After you have installed Intervention Image, open your Laravel config file config/app.php and add the following lines.

In the $providers array add the service providers for this package.

Intervention\Image\ImageServiceProvider::class

Add the facade of this package to the $aliases array.

'Image' => Intervention\Image\Facades\Image::class

Now the Image Class will be auto-loaded by Laravel.

Compressing and Encoding Images to WebP

After installing the interventions/image package update EditorImageUploadController and add the following lines:

--- a/app/Http/Controllers/EditorImageUploadController.php
+++ b/app/Http/Controllers/EditorImageUploadController.php
@@ -5,6 +5,8 @@
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+use Intervention\Image\Facades\Image;

 class EditorImageUploadController extends Controller
 {
@@ -20,7 +22,10 @@ public function __invoke(Request $request)
             ], 400);
         }

-        $path = $request->file('upload')->store('uploads', ['disk' => 'public']);
+        $filename = Str::ulid() . '.webp';
+        $path = 'uploads/' . $filename;
+        $defaultImage = Image::make($request->file('upload'))->encode('webp', 80);
+        Storage::disk('public')->put($path, (string)$defaultImage);
         $imageURL = Storage::disk('public')->url($path);

         return response()->json(['url' => $imageURL]);

This code generates a unique filename for the uploaded image in WebP format. The encode('webp', 80) is used to specify the encoding followed by quality. We are keeping the quality to 80 as this doesn’t affect the quality much and helps reduce the file size even further.

Now if you try to upload an image of around 1 MB the uploaded file should be compressed and converted to webp image of size around 200 KB.

Responsive Images with Resizing

CKEditor supports response images in which it’ll automatically add srcset attributes to the image. Update the controller code as follows:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Intervention\Image\Facades\Image;

class EditorImageUploadController extends Controller
{
    public function __invoke(Request $request)
    {
        $validator = Validator::make($request->all(), ['upload' => 'required|file|image|max:5000']);

        if ($validator->fails()) {
            return response()->json([
                'error' => [
                    'message' => $validator->errors()->first('upload')
                ]
            ], 400);
        }

        $defaultImage = Image::make($request->file('upload'));
        $imageWidth = $defaultImage->width();
        $imageSizes = [800, 1024, 1920];

        $filename = Str::ulid() . '.webp';
        $path = 'uploads/' . $filename;
        $defaultImage = $defaultImage->encode('webp', 80);
        Storage::disk('public')->put($path, (string)$defaultImage);
        $urls['default'] = Storage::disk('public')->url($path);

        foreach ($imageSizes as $size) {
            if ($imageWidth < $size) {
                break;
            }

            $image = Image::make($request->file('upload'))
                ->resize($size, null, fn($constraint) => $constraint->aspectRatio())
                ->encode('webp', 80);

            $path = 'uploads/' . Str::ulid() . "-$size.webp";
            Storage::disk('public')->put($path, (string)$image);
            $urls["$size"] = Storage::disk('public')->url($path);
        }

        $data = !empty($urls) ?
            ['urls' => $urls] :
            ['error' => ['message' => 'Upload failed!']];

        return response()->json($data);
    }
}

In this code, we are adding the $imageSizes we want to generate for and then loop over all to resize and store the image for each. Then map the response as per CKEditor’s file upload protocol. Now if you upload the image you should see the image with the srcset attributes added in the inspector.

Image HTML tag with srcset attributes

With the scrset in place, the browser will automatically load the optimized image based on the user's screen size and resolution.

Conclusion

Incorporating image resizing and optimization in your Laravel application with CKEditor 5 and the Intervention/Image package is a valuable approach to enhancing your website's performance, user experience, and SEO ranking. By ensuring that your images are not only visually appealing but also efficiently managed, you can provide a smoother and more engaging web experience for your audience. So, go ahead, optimize those images, and watch your website flourish. The source for this blog post can be found at this GitHub Repository.