petehouston/laravel-tinymce-simple-imageupload
Integrates TinyMCE's image upload with Laravel: adds a configurable upload endpoint that handles image files from the editor, validates and stores them on your disks, and returns the URL to TinyMCE for immediate insertion.
A thin package: it registers a single POST route that accepts a TinyMCE image payload, moves the file into public/, and returns the {"location": "<url>"} JSON shape TinyMCE expects. That's the whole contract — treat it as "the endpoint exists," not "the feature is done."
Minimal path:
Install
composer require petehouston/laravel-tinymce-simple-imageupload
On Laravel 5.5+ auto-discovery should register Petehouston\Tinymce\TinymceServiceProvider. On older versions, add it to config/app.php under providers — this is the first thing to check if routes 404 after composer dump-autoload.
Publish the config
php artisan vendor:publish --provider="Petehouston\Tinymce\TinymceServiceProvider"
You get config/tinymce.php (or similarly named — confirm the tag). This is where you set the upload directory, allowed extensions, and any size cap. Read the file top to bottom before touching TinyMCE; the defaults usually write into public/uploads or public/tinymce.
Confirm the route
php artisan route:list | grep -i tinymce
You'll see something like POST /tinymce/upload. Note the exact URI and any middleware the provider applies — you'll reference this URL in JavaScript and possibly add auth middleware to it.
Point TinyMCE at it
tinymce.init({
selector: 'textarea',
plugins: 'image',
toolbar: 'image',
images_upload_url: '/tinymce/upload',
automatic_uploads: true,
});
The moment this works is your first use case: pasting an image into the editor results in a file in public/ and an <img src> pointing at it.
The "where to look first" answer is always the service provider + the controller it references. Everything the package does lives in ~50 lines across those two files; skim them once and you'll know the package fully.
CSRF-safe upload handler (recommended baseline). The package route sits behind Laravel's web middleware group, which means CSRF applies. TinyMCE's default images_upload_url uploader does not send X-CSRF-TOKEN, so plain usage gives you a 419. Two options:
Option A — bypass for just this route in app/Http/Middleware/VerifyCsrfToken.php:
protected $except = ['tinymce/upload'];
Fast, but now anyone can POST files to your endpoint (see Gotchas). Fine for a low-traffic admin editor on a private network.
Option B — keep CSRF, override the uploader:
tinymce.init({
selector: 'textarea',
plugins: 'image',
images_upload_url: '/tinymce/upload',
images_upload_handler: (blobInfo) => new Promise((resolve, reject) => {
const data = new FormData();
data.append('file', blobInfo.blob(), blobInfo.filename());
fetch('/tinymce/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: data,
})
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(({ location }) => resolve(location))
.catch(reject);
}),
});
This is the pattern you'll actually ship. It also works for Sanctum/API auth by swapping the token header.
Locking down the route. Add auth to the endpoint in RouteServiceProvider:
Route::middleware(['web', 'auth'])->group(function () {
// route is already registered by the package; re-register or add middleware via the provider
});
If the package doesn't expose the route name cleanly, the pragmatic move is to disable the package route (comment out or filter in the provider) and define your own Route::post('tinymce/upload', [\Petehouston\Tinymce\Http\Controllers\TinymceController::class, 'upload'])->middleware('auth'). You get auth and control of the URL.
Per-user upload directories. The package writes to one configured directory. If you need uploads/{user_id}/, either extend the controller or subclass and override upload(). The common override:
$file = $request->file('file');
$path = 'tinymce/' . auth()->id();
$name = uniqid() . '.' . $file->getClientOriginalExtension();
$file->move(public_path($path), $name);
return response()->json(['location' => asset("$path/$name")]);
Framework frontend integration. In Vue/React/Livewire, initialize TinyMCE in onMounted/useEffect and pass the CSRF token from a meta tag or Inertia page props. Never hardcode the URL — read it from a config blob injected by Blade:
<meta name="tinymce-upload" content="{{ route('tinymce.upload') }}">
This survives domain changes and subdirectory installs.
Rich content pipeline. Once images upload, the editor output is a normal HTML string with src pointing at public/. Treat it as trusted only after you sanitize HTML with something like mews/purifier — TinyMCE content is not inherently safe if other plugins are enabled.
TinyMCE 6 changed the uploader contract. The 4/5 signature is (blobInfo, success, failure, progress); in 6 it's (blobInfo, progress) => Promise. Copy-pasting old tutorials into a modern project silently breaks the handler. Match the version in your tinymce.init.
The response shape is non-negotiable. TinyMCE expects { "location": "..." }. If your override returns { "url": ... } or a 200 with an HTML body, the upload "succeeds" but the image inserts as a broken icon with no console error explaining why. Check the network tab.
Public path only. The package writes into public/, not storage/. If you're on a container or read-only filesystem, public/ may not be writable or may not survive deploys. Either mount the directory as a volume or swap to Storage::disk('public') and storage:link in your override.
Field name is likely file. TinyMCE's default uploader posts the blob under file. If your overridden images_upload_handler appends it as image, the controller's $request->file('file') is null and you get a silent 422/500. Always append as file unless you also changed the controller.
PHP upload limits win. upload_max_filesize, post_max_size, and memory_limit in php.ini (or public/.htaccess php_value on Apache) cap uploads before the package's own size check runs. Large screenshots from modern monitors routinely exceed 2 MB defaults — set at least 8–10 MB for a photo-heavy editor.
SVG is an XSS vector. The config's allowed extensions often include everything the browser accepts. Strip svg from the allowed list unless you're serving images with Content-Disposition: attachment or a strict CSP. TinyMCE can render inline SVGs; stored XSS follows.
No orphan cleanup. Uploading then deleting the image in the editor leaves the file on disk forever. If this matters, add a queued job that reconciles files against saved content, or write through a dedicated disk and manage via Storage::delete on post updates.
Route caching. php artisan route:cache captures the package route as-is. If you add middleware by re-declaring the route, do it in a RouteServiceProvider (not a controller or web.php conditionally) — routes defined outside routes/*.php won't be cached and you'll get inconsistent 404s between local and production.
Config cache. php artisan config:cache in production means edits to config/tinymce.php need a config:clear + config:cache cycle. Publish-then-edit-then-forget is a classic 2 a.m. debugging trap.
Auth middleware is on you. By default the route is public. A public "upload arbitrary image" endpoint is a free image-hosting service and a disk-fill DoS. Add auth (and ideally throttle:10,1) before this touches production.
Extension points are thin. The package is ~50 lines. If you need thumbnails, EXIF stripping, virus scanning, or S3, you're not "extending" it — you're replacing the controller. The fastest path is to vendor-publish (if possible) or copy TinymceController::upload() into app/Http/Controllers/ImageUploadController.php and wire your own route. Don't fight the package for behaviors it was never designed for.
Maintenance signal. 66 stars and no recent activity is a red flag for Laravel upgrades — the provider may not support newer framework versions or PHP 8.3+ strictness. If you keep it, pin the version in composer.json and write one integration test that hits the endpoint end-to-end ($this->post('/tinymce/upload', ['file' => UploadedFile::fake()->image('a.png')])->assertJsonStructure(['location'])). That test is your upgrade canary.
How can I help you explore Laravel packages today?