Загрузка изображений в Ckeditor 5 для Laravel 13
Очень давно я выпускал на блоге статью про то, как подключить CKEditor 4 к Laravel 5.3. Она имела определённый успех. С тех пор всё это, конечно, сильно устарело — такими старыми инструментами уже никто не пользуется. Поэтому я решил вернуться к той же теме, но на более современном стеке.
В этой статье я хочу разобраться в том, как это делается сейчас — на CKEditor 5 и Laravel 13. Сначала поднимем чистый проект и подключим к нему редактор, а потом займёмся главным — загрузкой картинок на сервер.
Поднимаем проект с нуля
Если CKEditor у тебя ещё не стоит — давай по шагам соберём чистый проект, чтобы было от чего отталкиваться. Если редактор уже подключён и тебе нужна только загрузка — листай сразу к разделу про сервер.
Создаём свежий Laravel. Чтобы не отвечать на вопросы установщика вручную, сразу передаём всё флагами: стартовый кит, базу, тесты и сборку фронта. Берём Livewire-кит — он притащит готовый каркас вместе с Tailwind, и нам не придётся ничего настраивать руками.
$ laravel new ckeditor-demo \$ --livewire \$ --no-authentication \$ --database=sqlite \$ --npm \$ --no-interaction
Кит ставим без авторизации — для учебной заглушки она не нужна, поэтому --no-authentication убирает последние вопросы установщика, и он отрабатывает молча. С --no-interaction миграции прогоняются сами, sqlite-файл создаётся автоматически, так что проект сразу рабочий. Нужна не sqlite, а MySQL или Postgres — поменяй --database на mysql или pgsql.
Дальше ставим сам редактор. Тут важный момент: нужен ровно один пакет.
$ cd ckeditor-demo$ npm i ckeditor5
Если наткнёшься на старые гайды (обычно под Laravel 10), там ставят десяток отдельных пакетов вроде @ckeditor/ckeditor5-editor-classic и тащат плагин @ckeditor/vite-plugin-ckeditor5 в конфиг. Это устаревший способ. Сейчас всё лежит в одном пакете ckeditor5, и отдельный плагин для Vite не нужен.
Теперь подключаем редактор. Открываем resources/js/app.js и добавляем минимальный набор: сам редактор, обязательный Essentials, базовое форматирование и плагины для картинок. Стили CKEditor импортируются отдельной строкой — про это часто забывают, и потом редактор выглядит сломанным.
12345678910111213141516
import { ClassicEditor, Essentials, Paragraph, Bold, Italic, Image, ImageUpload } from 'ckeditor5';
import 'ckeditor5/ckeditor5.css';
const el = document.querySelector( '#editor' );
if ( el ) {
ClassicEditor
.create( {
attachTo: el,
licenseKey: 'GPL',
plugins: [ Essentials, Paragraph, Bold, Italic, Image, ImageUpload ],
toolbar: [ 'bold', 'italic', '|', 'uploadImage' ]
} )
.then( editor => console.log( 'Editor ready', editor ) )
.catch( err => console.error( err ) );
}
Дальше — страница-заглушка, на которой редактор будет жить. Создаём вьюху resources/views/editor.blade.php. Каркас от стартового кита уже подключает собранные ассеты, так что нам нужен только контейнер с id="editor" и пара классов Tailwind, чтобы это не висело в левом верхнем углу.
12345678910111213141516
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>CKEditor 5</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-100">
<div class="max-w-3xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold mb-4">Редактор</h1>
<div id="editor"><p>Привет! Это CKEditor 5.</p></div>
</div>
</body>
</html>
И роут на неё в routes/web.php:
1
Route::view('/editor', 'editor');
Запускаем сборку и сервер. В Laravel 13 удобнее всего одной командой — она поднимает и PHP-сервер, и Vite разом:
Открываем /editor в браузере — и видим рабочий CKEditor 5 с панелью инструментов. Кнопка загрузки картинки там уже есть, но если на неё нажать — ничего не выйдет: редактору пока некуда отправлять файл. Этим и займёмся.
Что поменялось со времён четвёрки
Если ты помнишь, как это работало в Ckeditor 4 — забудь. Там сервер возвращал кусок HTML со <script> внутри, который дёргал window.parent.CKEDITOR.tools.callFunction(). Выглядело это как костыль, потому что это и был костыль.
В пятёрке всё иначе. Редактор шлёт обычный POST с файлом, а в ответ ждёт чистый JSON. Успех — это:
123
{
"url": "https://example.com/media/foo.jpg"
}
Ошибка — это:
12345
{
"error": {
"message": "Файл слишком большой, максимум 2 МБ"
}
}
Причём message из ошибки Ckeditor сам покажет пользователю во всплывашке. Удобно: можно отдавать осмысленные тексты валидации прямо из Laravel, и они долетят до человека без единой строчки фронтового кода.
Серверная часть на Laravel 13
Тут начинается главное отличие от старого Laravel. Никаких замыканий прямо в роутах и никакого VerifyCsrfToken.php — этого файла в Laravel 11+ больше нет. Делаем по-человечески: контроллер.
$ php artisan make:controller ImageUploadController
Сам контроллер. Логика простая: провалидировать, что пришло именно изображение, сохранить в публичный диск, вернуть JSON в том формате, который ждёт Ckeditor 5.
123456789101112131415161718192021222324252627282930
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ImageUploadController extends Controller
{
public function store(Request $request): JsonResponse
{
try {
$request->validate([
'upload' => 'required|image|mimes:jpeg,png,gif,webp|max:2048',
]);
} catch (ValidationException $e) {
return response()->json([
'error' => ['message' => $e->validator->errors()->first('upload')],
]);
}
$path = $request->file('upload')->store('uploads', 'public');
return response()->json([
'url' => asset('storage/' . $path),
]);
}
}
Обрати внимание: поле называется upload — именно под этим именем Ckeditor 5 шлёт файл. Не file, не image. Если назвать иначе, валидация будет валиться, а ты будешь час втыкать в код, который выглядит правильно. Проверено.
Имя поля upload — самая частая причина «всё правильно, но не работает». Ckeditor 5 шлёт файл строго как upload.
Роут в routes/web.php:
123
use App\Http\Controllers\ImageUploadController;
Route::post('/upload-image', [ImageUploadController::class, 'store']);
В нашей демке авторизации нет, поэтому роут открыт — так он заведётся без логина. В реальном проекте обязательно прикрой его своей авторизацией (например, middleware('auth')), иначе лить файлы тебе на сервер сможет кто угодно.
И последнее — симлинк на публичный диск, без него asset('storage/...') будет вести в никуда:
$ php artisan storage:link
Подключаем загрузку в редакторе
Сервер готов принимать файлы — осталось научить редактор их отправлять. В CKEditor 5 за это отвечает upload adapter: маленький класс с двумя методами, upload() и abort(). Пишем его сами — это буквально тридцать строк, никаких зависимостей.
Возвращаемся в resources/js/app.js и добавляем адаптер. Он забирает файл, шлёт его на наш роут с CSRF-токеном в заголовке (защиту выключать не надо — токен едет в X-CSRF-TOKEN) и разбирает JSON-ответ.
12345678910111213141516171819202122232425262728293031323334353637383940
class MyUploadAdapter {
constructor( loader ) {
this.loader = loader;
}
upload() {
return this.loader.file.then( file => new Promise( ( resolve, reject ) => {
const data = new FormData();
data.append( 'upload', file );
fetch( '/upload-image', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document
.querySelector('meta[name="csrf-token"]')
.getAttribute('content')
},
body: data
} )
.then( res => res.json() )
.then( json => {
if ( json.error ) {
return reject( json.error.message );
}
resolve( { default: json.url } );
} )
.catch( reject );
} ) );
}
abort() {
}
}
function MyUploadAdapterPlugin( editor ) {
editor.plugins.get( 'FileRepository' ).createUploadAdapter = loader => {
return new MyUploadAdapter( loader );
};
}
Теперь подключаем плагин к редактору — добавляем extraPlugins в конфиг, который мы написали в начале:
12345678910
ClassicEditor
.create( {
attachTo: el,
licenseKey: 'GPL',
plugins: [ Essentials, Paragraph, Bold, Italic, Image, ImageUpload ],
extraPlugins: [ MyUploadAdapterPlugin ],
toolbar: [ 'bold', 'italic', '|', 'uploadImage' ]
} )
.then( editor => console.log( 'Editor ready', editor ) )
.catch( err => console.error( err ) );
Пересобираем фронт, обновляем страницу — и теперь кнопка загрузки реально работает: выбираешь файл, он улетает на сервер, возвращается URL, картинка появляется в тексте. Формат ответа адаптер ждёт ровно тот, что отдаёт наш контроллер — { default: url } на успех, reject(message) на ошибку.
На какие грабли наступишь
- Поле должно называться
upload. Ckeditor 5 шлёт файл именно так — переименуешь, валидация молча отвалится. - Ответ — строго JSON. Если Laravel вернёт HTML-страницу с ошибкой (например, 419 или 500), Ckeditor просто скажет «не удалось загрузить» без подробностей. Лови исключения и оборачивай в JSON.
- Не забыл импортировать CSS. Без
import 'ckeditor5/ckeditor5.css' редактор грузится, но выглядит сломанным — без рамок и панели. licenseKey: 'GPL' обязателен в свежих версиях. Без него редактор может ругаться в консоль или показывать ватермарку.- Не забудь
storage:link. Классика жанра: всё настроил, а картинки битые, потому что симлинка нет.
Чеклист
- Поставил
ckeditor5 одним пакетом, без legacy-плагинов - Импортировал
ckeditor5/ckeditor5.css в app.js - Положил
<meta name="csrf-token"> в шаблон - Написал upload adapter и подключил через
extraPlugins - Контроллер валидирует
image и отдаёт { url } / { error: { message } } - Поле в форме называется
upload - В демке роут открыт; в реальном проекте закрыт авторизацией
- Сделал
php artisan storage:link - Проверил, что при ошибке валидации сообщение долетает до всплывашки в редакторе
Вот и всё. Старую статью про четвёрку я трогать не буду — пусть ловит тех, кто реально сидит на легаси, — а эту повешу рядом как актуальную версию. Если у тебя Ckeditor 5 завёлся с первого раза без единой ошибки в консоли — поздравляю, ты везунчик, напиши в комментах как тебе это удалось.