Unmanaged Files in Drupal (Part 2): Building a Random File Handler

From Theory to Code: A Simple Service That Fetches Random Images
Unmanaged Files in Drupal: Building a Random File Handler (Part 2)

In Part 1 of this series we set the stage for using unmanaged files—assets Drupal doesn’t track as entities—to keep certain use cases simple. In Part 2 we’ll write a small Drupal service that scans a folder tree of images and returns one random file. No categories or constraints yet, that comes later—just proof that Drupal can “see” and use files, living in public://, private://, or even a directory outside the web root, without managing them.

What we’re building

  • A custom module: unmanaged_files
  • A service: unmanaged_files.handler with a single method getRandomFile()
  • (For testing) a route /unmanaged-files/test that renders the picked image

Expected file locations

The images I will be using are available in the resources section of my blog or on GitHub. The links are both at the end of this post.

If you are using a server, you can use an application like Filezilla to place the files, or a utility such as scp or rsync. If you use rsync, an example of the syntax is given in Part 1.

You can use your own images, instead. If you do, be sure to organize them in category folders under a folder in public://. Whether your own images or those that I am using, and however you get them there, your images should be located as shown in Figure 1 or something similar.

public://segregated_maps/<category>/<your-images>
# example real path (will vary): web/sites/default/files/segregated_maps/africa/algieria.png

Figure 1. File structure for segregated maps

Scaffold the module (Drush)

You can scaffold the module manually. I prefer to use the drush generate command as shown in Figure 2. Enter is pressed at the end of each line.

drush generate module
 Module name:
 ➤ Unmanaged Files
 Module machine name [unmanaged_files]:
 ➤ 
 Module description:
 ➤ Unmanaged files example
 Package [Custom]:
 ➤ 
 Dependencies (comma separated):
 ➤ 
 Would you like to create module file? [No]:
 ➤ 
 Would you like to create install file? [No]:
 ➤ 
 Would you like to create README.md file? [No]:
 ➤ 

Figure 2. Generating the module with Drush

Register the service

In Drupal, business logic is usually wrapped inside a service. Services are reusable PHP classes registered in the container, so they can be injected into controllers, plugins, or other classes instead of being called directly with \Drupal::service().

Here we define a new service called unmanaged_files.handler. All it does, for now, is give Drupal a way to locate and instantiate our FileHandler class that will handle files outside of Drupal's management, passing in core’s file_system and stream_wrapper_manager services so our class can resolve public:// paths into real filesystem locations.

Create the file web/modules/custom/unmanaged_files/unmanaged_files.services.yml as shown in Figure 3.

services:
  unmanaged_files.handler:
    class: Drupal\unmanaged_files\Service\FileHandler
    arguments:
      - '@file_system'
      - '@stream_wrapper_manager'

Figure 3. Service definition

The handler (minimal)

The handler is the heart of this tutorial part. It’s a simple PHP class with one public method: getRandomFile(). This method scans everything under public://segregated_maps, collects the files it finds, and returns one at random.

A few key details:

  • We use PHP’s RecursiveDirectoryIterator to walk all folders and subfolders.
  • We convert each absolute filesystem path back into a Drupal stream wrapper URI (public://…) so the result can be used with Drupal’s file APIs.
  • If no files are found, the method returns NULL.

The goal isn’t to be clever yet—it’s just to prove that Drupal can “see” unmanaged files and hand you one at random.

Create the file web/modules/custom/unmanaged_files/src/Service/FileHandler.php as shown in Figure 4.

<?php
namespace Drupal\unmanaged_files\Service;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
final class FileHandler {
  public function __construct(
    private FileSystemInterface $fs,
    private StreamWrapperManagerInterface $swm,
  ) {}
  /**
   * Returns a single random file URI under public://segregated_maps,
   * or NULL if none found.
   *
   * @return string|null  e.g., 'public://segregated_maps/africa/algeria.png'
   */
  public function getRandomFile(): ?string {
    $baseUri = 'public://segregated_maps';
    $basePath = $this->fs->realpath($baseUri);
    if (!$basePath || !is_dir($basePath)) {
      return NULL;
    }
    $files = [];
    $iter = new \RecursiveIteratorIterator(
      new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS)
    );
    foreach ($iter as $f) {
      if ($f->isFile()) {
        $abs = $f->getPathname(); // absolute path on disk
        // Convert absolute path back to a public:// URI.
        // $basePath maps to $baseUri.
        $rel = ltrim(substr($abs, strlen($basePath)), DIRECTORY_SEPARATOR);
        $files[] = $baseUri . '/' . str_replace(DIRECTORY_SEPARATOR, '/', $rel);
      }
    }
    if (!$files) {
      return NULL;
    }
    return $files[array_rand($files)];
  }
}

Figure 4. Minimal file handler

Quick test route (optional, but handy)

Add a tiny controller + route so you can see an actual image in the browser. Create the file web/modules/custom/unmanaged_files/unmanaged_files.routing.yml as shown in Figure 5.

unmanaged_files.test:
  path: '/unmanaged-files/test'
  defaults:
    _controller: '\Drupal\unmanaged_files\Controller\TestController::view'
    _title: 'Unmanaged files test'
  requirements:
    _permission: 'access content'

Figure 5. Test route definition

And the file web/modules/custom/unmanaged_files/src/Controller/TestController.php as shown in Figure 6.

<?php
namespace Drupal\unmanaged_files\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\unmanaged_files\Service\FileHandler;
use Drupal\Core\File\FileUrlGeneratorInterface;
final class TestController extends ControllerBase {
  public function __construct(
    private FileHandler $handler,
    private FileUrlGeneratorInterface $urlGen,
  ) {}
  public static function create(ContainerInterface $c): self {
    return new self(
      $c->get('unmanaged_files.handler'),
      $c->get('file_url_generator'),
    );
  }
  public function view(): array {
    $uri = $this->handler->getRandomFile();
    if (!$uri) {
      return [
        '#markup' => '<p>No files found under <code class="inline">public://segregated_maps</code>.</p>',
      ];
    }
    $url = $this->urlGen->generateAbsoluteString($uri);
    return [
      '#type' => 'container',
      '#attributes' => ['class' => ['unmanaged-files-test']],
      'info' => ['#markup' => '<p>Picked: <code class="inline">' . $uri . '</code></p>'],
      'img'  => [
        '#type' => 'html_tag',
        '#tag' => 'img',
        '#attributes' => ['src' => $url, 'alt' => 'Random unmanaged file'],
      ],
      '#cache' => [
        'max-age' => 1,
      ],
    ];
  }
}

Figure 6. Test controller

For demo purposes we set a very short cache lifetime so images rotate on refresh.

Enable & test

drush en unmanaged_files -y
drush cr

Then open your site in a browser and visit:

https://yoursite.example/unmanaged-files/test

You should see one of your images and the URI it came from, e.g. public://segregated_maps/africa/algeria.png. Refresh the page a few times to confirm that the handler is rotating files.

Get the code and image files

Download the module code and the sample map images from the Part 2 release page on GitHub. Unzip the images into web/sites/default/files/segregated_maps before running the tests.

What’s next

With a working handler, Parts 3–5 will show three ways to render this in the site: preprocess variable, block plugin, and Twig function. In Part 6 we’ll add the “no more than one per category” selection rule to the file handler.

Note: The vision of this web portal is to help promote news and stories around the Drupal community and promote and celebrate the people and organizations in the community. We strive to create and distribute our content based on these content policy. If you see any omission/variation on this please reach out to us at #thedroptimes channel on Drupal Slack and we will try to address the issue as best we can.

Related Organizations

Upcoming Events

Latest Opportunities