Add an image gallery to a rich text editor in PHP

The Insert Gallery dialog showing the blog, products and team folders and four images from the PHP sample's uploads folder, one selected

The editor's Insert Gallery dialog can show the images that are already on your server, so people pick a picture instead of uploading the same one again. All it needs is one URL that answers with JSON. This page gives you that endpoint in plain PHP 8: a script that reads a folder under uploads/ with scandir() and lists its subfolders and images.

Turn the gallery on

Set galleryEndpoint when you create the editor. The dialog then asks that URL for a listing each time it opens or changes folder.

HTML · index.php
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
<script>
    new RichTextEditor("#editor", {
        toolbar: "full",
        galleryEndpoint: "/rte/gallery",
        maxUploadFileSize: 4000000     // same limit as rte/upload.php, checked before sending
    });
</script>

rte-files.js is the small browser file that sends uploads from the dialog to /rte/upload. The image upload guide shows the server side of that.

The route

GET /rte/gallery?folder=blog returns the listing of one folder. The script is short, because the work is in two helpers:

PHP · rte/gallery.php
if ($method === 'GET') {
    return rte_listing(rte_resolve_folder($_GET['folder'] ?? ''));
}

rte_resolve_folder() turns the virtual folder from the query string into a real directory inside the storage root, or answers 400 or 404. The folders guide explains it. rte_listing() builds the JSON.

What the gallery receives

The dialog needs the current folder, its parent (null at the root), the subfolders, and the images with their URLs and sizes:

The JSON response of GET /rte/gallery?folder=blog from the PHP sample: currentFolder blog, parentFolder empty, no subfolders, and images with url, name, folder and size
The real response from the sample, shortened to two images.

This is the code that produces it:

PHP · rte/files-lib.php
// The listing for one folder, in the shape the gallery dialog expects.
function rte_listing(array $folder): array
{
    $folders = [];
    $images = [];
    $names = scandir($folder['dir']);
    if ($names === false) {
        throw new RteHttpError(500, 'The folder cannot be read.');
    }
    foreach ($names as $name) {
        if ($name === '' || $name[0] === '.') {
            continue; // ".", "..", and hidden files such as .htaccess
        }
        $path = $folder['dir'] . DIRECTORY_SEPARATOR . $name;
        // Skip symlinks and Windows junctions. is_link() alone is not enough: PHP 8.2 on
        // Windows returns false for a junction, so also require realpath() to be unchanged.
        $real = realpath($path);
        $same = DIRECTORY_SEPARATOR === '\\' ? strcasecmp($real ?: '', $path) === 0 : $real === $path;
        if (is_link($path) || !$same) {
            continue;
        }
        $segments = [...$folder['segments'], $name];
        if (is_dir($path)) {
            if (rte_is_valid_name($name) && !rte_is_reserved($segments)) { // only folders the routes accept back
                $folders[] = ['folder' => implode('/', $segments), 'name' => $name];
            }
        } elseif (is_file($path) && in_array(rte_extension($name), RTE_IMAGE_EXTENSIONS, true)) {
            $images[] = [
                'url' => rte_url($folder['root'], $segments),
                'name' => $name,
                'folder' => $folder['virtual'],
                'size' => (int)filesize($path),
            ];
        }
    }
    $byName = fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']) ?: strcmp($a['name'], $b['name']);
    usort($folders, $byName);
    usort($images, $byName);

    $virtual = $folder['virtual'];
    $parent = null;
    if ($virtual !== '') {
        $cut = strrpos($virtual, '/');
        $parent = $cut === false ? '' : substr($virtual, 0, $cut);
    }
    return [
        'currentFolder' => $virtual,
        'currentFolderDisplay' => '/' . $virtual,
        'parentFolder' => $parent,
        'folders' => $folders,
        'images' => $images,
    ];
}

A few choices in it are worth knowing about:

  • Names starting with a dot are skipped, so uploads/.htaccess never shows up.
  • Only .jpg .jpeg .png .gif .webp files are listed. Anything else in the folder stays invisible.
  • Symlinks and Windows junctions are skipped. On Windows, PHP 8.2's is_link() returned false for a junction when we tested it, so the code also checks that realpath() leaves the path unchanged.
  • Each part of an image URL goes through rawurlencode(), so my photo.jpg is listed as /uploads/products/my%20photo.jpg.
  • Folders and images are sorted by name without regard to case, with strcasecmp().

Formats the dialog offers and the server accepts

The released gallery dialog lists "JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF" as accepted formats, and its file picker offers those. This server accepts five of them: .jpg .jpeg .png .gif .webp. If someone picks a .bmp file, the dialog sends it, the server answers 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}, and the dialog shows "Upload failed for photo.bmp.". The server's reason is in the response body, which you can see in the browser's developer tools.

Size is different: with maxUploadFileSize: 4000000 the dialog refuses a large file before sending it. A 4.5 MB file gave "big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB." (the dialog counts in binary megabytes).

Serving the images

The listing points at /uploads/..., so those URLs must return the files. In the sample project, router.php serves them for PHP's built-in server. It sends only image extensions, with a fixed Content-Type and X-Content-Type-Options: nosniff, and never runs anything from that folder. On Apache or nginx the web server serves them directly. Keep its no-execute rule in place, as shown in the secure upload guide.

Test it

Start the sample project for this guide with php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php, then ask for a listing:

Shell
curl "http://localhost:5201/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}

curl "http://localhost:5201/rte/gallery?folder=blog/2027"
{"error":"Folder not found."}

The second request gets HTTP 404. Open http://localhost:5201/ and click Insert Gallery: the dialog shows the root's folders and images, and clicking a folder opens it.

The same guide for other platforms

JavaScript · Next.js · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

Read next