Unmanaged Files - 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
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
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
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
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
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>public://segregated_maps</code>.</p>',
];
}
$url = $this->urlGen->generateAbsoluteString($uri);
return [
'#type' => 'container',
'#attributes' => ['class' => ['unmanaged-files-test']],
'info' => ['#markup' => '<p>Picked: <code>' . $uri . '</code></p>'],
'img' => [
'#type' => 'html_tag',
'#tag' => 'img',
'#attributes' => ['src' => $url, 'alt' => 'Random unmanaged file'],
],
'#cache' => [
// Set the cache life to a minimal duration, for now, so that images change.
'max-age' => 1,
],
];
}
}
Figure 6
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.