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.
app/Models/BlogPost.php
database/migrations/2023_10_29_125434_create_blog_posts_table.php
database/factories/BlogPostFactory.php
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
.
- The
index
retrieves paginated blog posts from the database and then passes data to view. - The
edit
retrieves a single blog post using the Route Model binding technique and passes data to view. - The
update
takes an incoming request object and blog post instance as parameters and then performs validation on the request for thetitle
andbody
fields. Then passes the validated data to the model instance to perform the update request. Finally, it redirects back to the previous page.
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:
resources/views/layouts/admin.blade.php
resources/views/blog-posts/index.blade.php
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:
- Using file managers (CKBox file manager or CKFinder file manager)
- Easy Image integration
- Base64 image upload adapter
- 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:
- Visit https://ckeditor.com/ckeditor-5/online-builder/
- Select Classic Editor.
- Remove the cloud services plugin from the selected options.
- Select the
Simple Upload Adapter
plugin. - Keep the toolbar selection default.
- Select your preferred language.
- 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:
- 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). - If the validation fails, it returns a JSON response with an error message and a 400 (Bad Request) status code.
- If the validation passes, it stores the uploaded image in the 'public' disk under the 'uploads' directory and retrieves its URL.
- 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.

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.