File System and Private Files

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Every Drupal site handles files: hero images for exhibitions, PDF catalogues, audio guides, and documents that must never be public. Drupal does not treat a file as a path on disk. It addresses a file through a URI whose scheme picks the storage (public://, private://, temporary://) and records every file it manages as a file entity with a usage count. This page explains the schemes and the stream wrappers behind them, the settings that point them to a directory, managed and unmanaged files, how Drupal decides who may download a private file, how uploads are validated and their names sanitised, how temporary files are cleaned up, and how to keep the files consistent when the site runs on more than one machine. It was checked against the official documentation on 2026-10-30 and written for Drupal 11.4.x with PHP 8.3 or later. Drupal 10 reaches its end of life on 2026-12-09, so new sites should not start on it, and Drupal 12.0.0 is scheduled for the week of 2026-12-07; the version notes below say what changes there.

The running example is the Meridian Museum: exhibition images are public, while scanned loan agreements and unpublished catalogue drafts are private. Related pages are Media and the Media Library, Display modes and image styles, settings.php and environments, Security hardening, Cron, queues and maintenance, Docker and containers and Backups and disaster recovery.

Schemes and stream wrappers

PHP lets code open scheme://path locations through stream wrappers. Drupal builds on that: a URI such as public://exhibitions/hero.jpg is turned into a real location (a local path, an HTTP URL, an object-store key) by the stream wrapper registered for the public scheme. Code that always works with URIs and never with absolute paths keeps working when the storage moves.

Scheme Where it points Typical use

public://

sites/default/files by default (the value of file_public_path), inside the web root

Images, CSS and JavaScript aggregates, image-style derivatives, anything every visitor may fetch directly from the web server

private://

The directory set with file_private_path, ideally outside the web root; not available until that setting exists

Files whose download Drupal must authorise: loan agreements, member-only PDFs, drafts

temporary://

The system temporary directory (file_temp_path, else the PHP default), outside the web root

Uploads in progress, export files, scratch data; not meant to survive a request or a server restart

assets://

A directory below the public path used for generated assets such as aggregated CSS and JavaScript

Built by the asset system; you rarely write to it

The base classes are Drupal\Core\StreamWrapper\PublicStream, PrivateStream and TemporaryStream, and a wrapper is registered by tagging a service with stream_wrapper (the scheme is a tag attribute). The File system settings page at /admin/config/media/file-system shows the resolved public, private and temporary paths, and the status report warns when a configured directory is missing or not writable.

flowchart LR code["Module code - private://loans/a.pdf"] --> sw{"Stream wrapper - registry"} sw -->|public| pub["file_public_path - web root"] sw -->|private| priv["file_private_path - outside web root"] sw -->|temporary| tmp["file_temp_path"] sw -->|s3 contrib| s3["Object store - bucket"] pub --> web["Web server serves - the file directly"] priv --> ctl["Drupal controller - checks access"] s3 --> cdn["Bucket or CDN URL"]

Because public:// files are served by the web server without starting PHP, nothing in Drupal can refuse them: anyone who knows or guesses the URL gets the file. Use the public scheme only for content that is meant to be public.

Configuring the directories

The three paths are settings, not configuration: they live in settings.php (see settings.php and environments) so that they can differ per environment and are not exported with the configuration.

<?php

// web/sites/default/settings.php

// Public files: the default is sites/default/files; change it only when you must.
$settings['file_public_path'] = 'sites/default/files';

// Private files: outside the web root. In a project created from drupal/recommended-project
// the web root is web/, so ../private sits next to it.
$settings['file_private_path'] = $app_root . '/../private';

// Temporary files: defaults to the PHP temporary directory.
$settings['file_temp_path'] = '/tmp';

// Permissions given to directories and files Drupal creates.
$settings['file_chmod_directory'] = 0775;
$settings['file_chmod_file'] = 0664;

Create the directory before pointing to it, and make it writable by the web server user only:

mkdir -p private/loans
# Owned by the account PHP-FPM runs as; no access for others.
chmod 750 private
ddev drush cache:rebuild
ddev drush status-report --severity=1   # or open /admin/reports/status

Common pitfalls with these settings:

  • The private path does not exist yet. Drupal does not create it. Until the setting is present and the directory is writable, the private:// scheme is simply unavailable, and the field storage form hides the "Private files" choice.

  • The private path is inside the web root. Drupal writes an .htaccess that denies direct access when it can, but that file is honoured by Apache only. On nginx, or when AllowOverride is None, the directory is open. Keep it outside the web root, or add an explicit deny rule.

  • A changed path is not picked up. Rebuild caches after changing file_private_path, because the stream wrapper registry and cached URLs hold the old value.

  • Different values on different machines. Every web server of the same site must see the same files at the same paths; see Scaling out.

  • Containers. In DDEV the project root is mounted at /var/www/html, so ../private resolves next to web/ and persists with the project. In an image-based deployment the directory must be a mounted volume or remote storage, otherwise uploads vanish with the container.

A web-server rule that keeps a misplaced private directory closed:

# Only needed when the private directory is below the web root.
location ~ ^/sites/default/private/ {
    deny all;
    return 403;
}

Managed and unmanaged files

Drupal distinguishes two ways of handling files.

Managed files are recorded in the database. The file module defines the file content entity (table file_managed) with the fields uri, filename, filemime, filesize, status and uid, plus a file_usage table that records which entity uses which file. Upload widgets (managed_file, image and file fields, the Media Library) always create managed files. Because the file is an entity it has an owner, can be loaded in Views and JSON:API, fires entity hooks and takes part in access checks.

Unmanaged files are plain files on disk that Drupal does not track: a generated sitemap, a cached export, a log. Use the file_system service for them and take responsibility for cleaning up.

The file system service

The file_system service (Drupal\Core\File\FileSystemInterface) is the API for working with files on disk. Its important methods are:

Method Purpose

prepareDirectory(&$directory, $options)

Checks that a directory exists and is writable; with FileSystemInterface::CREATE_DIRECTORY it creates the missing directories, and with MODIFY_PERMISSIONS it fixes the permissions

saveData($data, $destination, $fileExists)

Writes a string to a URI and returns the final URI

copy($source, $destination, $fileExists) and move()

Copy or move a file between URIs

delete($path) and deleteRecursive($path)

Remove a file or a directory tree

realpath($uri)

The local path of a URI; returns FALSE for wrappers that have none, such as an object store

getTempDirectory() and tempnam()

Locate the temporary directory and create a unique temporary file

moveUploadedFile($filename, $uri)

Moves a PHP upload into place

createFilename($basename, $directory)

Makes a name that does not collide with an existing file

The third argument of copy(), move() and saveData() decides what happens when the destination exists. Since Drupal 10.3 it is the Drupal\Core\File\FileExists enum with the cases Rename, Replace and Error. The constants FileSystemInterface::EXISTS_RENAME, EXISTS_REPLACE and EXISTS_ERROR are deprecated since 10.3.0 and are listed for removal in Drupal 12.0.0, so new code uses the enum.

Creating a managed file from code

The following service belongs to the meridian_collection module. It stores a scanned loan agreement in the private scheme and registers its usage, so the file survives the temporary-file cleanup.

# web/modules/custom/meridian_collection/meridian_collection.services.yml
services:
  meridian_collection.loan_scans:
    class: Drupal\meridian_collection\LoanScanStorage
    arguments:
      - '@file_system'
      - '@file.repository'
      - '@file.usage'
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\file\FileInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\file\FileUsage\FileUsageInterface;

/**
 * Stores scanned loan agreements in the private file system.
 */
final class LoanScanStorage {

  public function __construct(
    private readonly FileSystemInterface $fileSystem,
    private readonly FileRepositoryInterface $fileRepository,
    private readonly FileUsageInterface $fileUsage,
  ) {}

  /**
   * Saves PDF bytes for a collection object and records which entity uses them.
   */
  public function save(string $pdfBytes, string $accessionNumber, int $entityId): FileInterface {
    $directory = 'private://loans';
    // Create the directory (and fix its permissions) if it is missing.
    $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);

    // writeData() saves the bytes, creates the file entity and avoids name collisions.
    $file = $this->fileRepository->writeData($pdfBytes, "$directory/loan-$accessionNumber.pdf", FileExists::Rename);

    // Without usage the file stays temporary and cron deletes it.
    $file->setPermanent();
    $file->save();
    $this->fileUsage->add($file, 'meridian_collection', 'collection_object', $entityId);

    return $file;
  }

}

The file.repository service (FileRepositoryInterface) is the entity-aware counterpart of file_system: writeData(), copy(), move() and loadByUri() create or update the file entity together with the bytes. Calling file_system->saveData() alone creates an unmanaged file, which is a frequent source of orphans.

File usage and the file status

A managed file has a status: temporary (0) or permanent (1). A new upload is temporary until something claims it. The file.usage service (Drupal\file\FileUsage\FileUsageInterface) keeps the claims: add($file, $module, $type, $id, $count), delete() and listUsage($file). The columns are the file ID, the module that registered the usage, the entity type and ID that uses it, and a count. Field widgets add and remove usage automatically when an entity is saved, deleted or loses a revision; custom code that stores a file ID by hand must call add() itself.

Two settings control the cleanup, both editable at /admin/config/media/file-system or with Drush:

  • system.file:temporary_maximum_age is the age in seconds after which cron deletes temporary files. The default is 21600 (six hours), and 0 disables the deletion.

  • file.settings:make_unused_managed_files_temporary is off by default. When enabled, a file whose last usage is removed becomes temporary and is deleted by cron once it passes the maximum age. When disabled, orphans accumulate on disk.

ddev drush config:set system.file temporary_maximum_age 21600 -y
ddev drush config:set file.settings make_unused_managed_files_temporary true -y
ddev drush cron

Deleting a file entity is blocked in the UI while it has usage, and a file with usage from several revisions stays until all of them are deleted. Wide-ranging cleanup of old revisions therefore also frees disk space; see Content moderation, workflows and revisions.

Private files in detail

Setting file_private_path makes the scheme available; it does not decide who may download. Marking a field’s Upload destination as Private files (field storage settings) places new files below private://, and Drupal serves them through the route system.files (/system/files/{path}), handled by FileDownloadController. The controller:

  1. resolves the requested path to a private:// URI and refuses anything that escapes the directory;

  2. calls hook_file_download($uri) in every module;

  3. if any module returns -1 the answer is 403 Forbidden; if none returns an array of headers, the answer is 403 too; if at least one returns headers and none denies, the file is streamed with those headers.

The file module implements the hook with the default policy: a permanent file is downloadable by a user who may view an entity that references it. This is the key consequence for editors and administrators: a private file attached to a published node is available to every visitor who may see the node. The private scheme protects against guessing the URL, not against seeing the content. Files referenced only by unpublished content are denied to users who cannot view that content, and a temporary file is visible only to its owner.

Because every download starts PHP, private files cost far more than public ones; they are also not cacheable by the web server. Do not put the site’s images in the private scheme unless the images themselves are confidential.

Writing your own access rule

The Meridian curators want loan agreements visible only to users with a view loan agreements permission, even when the collection object is public. The hook_file_download implementation below grants the headers only to them and denies everyone else, which overrides the default policy because one -1 wins.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;

/**
 * Access control for loan agreements stored in the private file system.
 */
final class LoanFileDownloadHooks {

  public function __construct(private readonly AccountInterface $currentUser) {}

  #[Hook('file_download')]
  public function fileDownload(string $uri): array|int|null {
    // Not one of our files: stay neutral so other modules can decide.
    if (!str_starts_with($uri, 'private://loans/')) {
      return NULL;
    }
    if (!$this->currentUser->hasPermission('view loan agreements')) {
      return -1;
    }
    return [
      'Content-Type' => 'application/pdf',
      'Content-Disposition' => 'attachment; filename="' . basename($uri) . '"',
      'Cache-Control' => 'private',
    ];
  }

}

The class goes in src/Hook/ and is autowired, which needs Drupal 11.1 or later; on 10.x the same logic lives in a procedural meridian_collection_file_download() function in the .module file. See Hooks and OOP hooks. The return contract is: NULL to abstain, -1 to deny, an array of headers to allow. The permission itself is declared in a meridian_collection.permissions.yml file, as shown in Users, roles and permissions.

Security notes for private files

  • Direct access. The private directory must not be reachable over HTTP. Test it: request a known file at its would-be public path and expect a 403 or 404.

  • Sensitive data in filenames. The filename travels in the URL and in logs. Do not put names, ID numbers or emails in it.

  • Headers. Return Content-Disposition: attachment for types that a browser would otherwise render (HTML, SVG), so an uploaded file cannot run script in your origin.

  • Image derivatives. Image styles of a private image are generated through Drupal on request, with a token (itok) derived from the private key; see Display modes and image styles.

  • Backups. The private directory holds data the database dump does not. Back it up with the database; see Backups and disaster recovery.

  • Reverse proxies. A CDN or Varnish in front of the site must not cache /system/files/ responses for anonymous users unless the file is truly public; see Performance and caching.

Upload validation

An upload passes through several checks before it becomes a file.

Validators as constraints

Until Drupal 10.1 a form element declared validators as function names (file_validate_extensions, file_validate_size, file_validate_is_image). Since Drupal 10.2 validation uses Symfony validator constraint plugins in the Drupal\file\Plugin\Validation\Constraint namespace and the file.validator service (Drupal\file\Validation\FileValidatorInterface). The procedural file_validate() family was deprecated in 10.2.0 and is gone in Drupal 11, and passing arbitrary data in #upload_validators is no longer accepted. Code written for Drupal 10.0, including the examples of older books such as Drupal 10 Development Cookbook (2023), still uses the function names and fails on Drupal 11 with a plugin-not-found error.

Old function Constraint ID Option

file_validate_extensions

FileExtension

extensions

file_validate_size

FileSizeLimit

fileLimit

file_validate_name_length

FileNameLength

none

file_validate_is_image

FileIsImage

none

file_validate_image_resolution

FileImageDimensions

maxDimensions

The FileExtensionSecure constraint always runs inside FileValidator::validate(). It rejects the insecure extensions listed in FileSystemInterface::INSECURE_EXTENSIONS (phar, php, pl, py, cgi, asp, js, htaccess and phtml) unless the Allow insecure uploads option is enabled, which production sites should never do. Uploaded files with these extensions are otherwise renamed by a security subscriber so the web server does not execute them.

hook_file_validate is deprecated as well. A module that needs to veto a file, such as a virus scanner, subscribes to Drupal\file\Validation\FileValidationEvent and adds violations to the list.

A managed upload element

The settings form of meridian_collection lets curators upload a catalogue logo with strict validation:

<?php

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Utility\Bytes;

public function buildForm(array $form, FormStateInterface $form_state): array {
  $form['logo'] = [
    '#type' => 'managed_file',
    '#title' => $this->t('Catalogue logo'),
    '#upload_location' => 'public://meridian/branding',
    '#upload_validators' => [
      'FileExtension' => ['extensions' => 'png svg'],
      'FileSizeLimit' => ['fileLimit' => Bytes::toNumber('2 MB')],
      'FileImageDimensions' => ['maxDimensions' => '1200x1200'],
    ],
  ];
  return $form;
}

The #upload_location is a URI, never an absolute path. On submit the form receives the file ID, loads the entity, marks it permanent and records usage; if the form forgets to do so the file is deleted by cron after six hours. See Form API.

For file and image fields the same constraints are configured in the field settings (Allowed file extensions, Maximum upload size, Maximum image resolution). The limits you can set in Drupal never exceed what PHP allows: upload_max_filesize and post_max_size in php.ini, and client_max_body_size in nginx. A 413 error or an upload that silently drops usually comes from one of those.

Validating in code

<?php

/** @var \Drupal\file\Validation\FileValidatorInterface $validator */
$validator = \Drupal::service('file.validator');
$violations = $validator->validate($file, [
  'FileExtension' => ['extensions' => 'pdf'],
  'FileSizeLimit' => ['fileLimit' => 10 * 1024 * 1024],
]);
$messages = [];
foreach ($violations as $violation) {
  $messages[] = (string) $violation->getMessage();
}

validate() returns a ConstraintViolationListInterface, not an array of strings as the old file_validate() did, so collect the messages as above.

Filename sanitisation

Names supplied by visitors can contain characters that break scripts, shells or file systems. Drupal handles this in layers.

  • Security renames. The core subscriber SecurityFileUploadEventSubscriber neutralises dangerous extensions (the behaviour of the old file_munge_filename() function).

  • Optional sanitisation, since Drupal 10.2. Core dispatches Drupal\file\Event\FileUploadSanitizeNameEvent on every upload through the UI or REST. Subscribers may change the name but not the extension. The default subscriber reads the filename_sanitization key of file.settings, edited at /admin/config/media/file-system, with options to transliterate the name, replace whitespace with a dash or an underscore, replace characters outside 0-9, A-Z, a-z, -, _ and ., collapse duplicate separators, and convert to lower case. Transliteration is off by default because it is not useful for every language.

  • Drupal 12. The change record "Dangerous characters in filenames are replaced" (introduced in 12.0.0, published 2026-09-19) makes core replace dangerous characters such as shell metacharacters with underscores for everyone. For example a;b|c&d.txt becomes a_b_c_d.txt, and a name of only symbols such as ---.txt becomes unnamed.txt. Accents, spaces and most safe Unicode characters (emoji included) are kept unless transliteration is configured, and existing files are not renamed. The change record does not list configuration keys, so check the Transliteration settings of your release.

The configuration keys have been written in two spellings in published material: the 10.2 change record’s example uses dedupe_separators, while the default subscriber’s code reads deduplicate_separators. Export the configuration of your own site (drush config:get file.settings filename_sanitization) and use the key it contains.

An additional subscriber for Meridian prefixes uploads to the collection with the current year:

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\EventSubscriber;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\file\Event\FileUploadSanitizeNameEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class UploadNameSubscriber implements EventSubscriberInterface {

  public function __construct(private readonly TimeInterface $time) {}

  public static function getSubscribedEvents(): array {
    return [FileUploadSanitizeNameEvent::class => 'onSanitize'];
  }

  public function onSanitize(FileUploadSanitizeNameEvent $event): void {
    $name = $event->getFilename();
    $year = date('Y', $this->time->getRequestTime());
    if (!str_starts_with($name, $year . '-')) {
      // The extension cannot be changed here, only the base name.
      $event->setFilename($year . '-' . $name);
    }
  }

}

Register it in meridian_collection.services.yml with the event_subscriber tag; see Events and subscribers. The event covers uploads through the UI and the REST file upload resource, not files that your own code writes with file_system. Filename changes also change URLs, so redirect or keep the originals where external links point at files.

Serving files

Use the URL generator, never string concatenation:

<?php

/** @var \Drupal\Core\File\FileUrlGeneratorInterface $generator */
$generator = \Drupal::service('file_url_generator');
$absolute = $generator->generateAbsoluteString('public://exhibitions/hero.jpg');
$relative = $generator->generateString('public://exhibitions/hero.jpg');
$private = $generator->generateAbsoluteString('private://loans/loan-1949.pdf');

For the private scheme the generator returns a /system/files/... URL that goes through the controller above; for public it returns a direct path below file_public_path. A page that embeds a private URL is cacheable only per user or per permission, so render caching needs the correct cache contexts (see Render API and cache metadata).

Public files can be served from another host with $settings['file_public_base_url'] = 'https://files.example.org/files';, which suits a CDN or an object-store bucket; the URL must answer the same relative paths.

Remote storage with the S3 File System module

A local disk is the wrong place for files when several web servers share a site or the web tier is disposable. The contributed S3 File System module (drupal/s3fs, project page https://www.drupal.org/project/s3fs) registers a stream wrapper for Amazon S3 and S3-compatible services. It can take over the public:// and private:// schemes or serve only chosen fields, and it keeps a metadata cache in the database to avoid listing the bucket on every request. It requires the AWS SDK for PHP v3, installed by Composer, and PHP with allow_url_fopen enabled. Its project page lists a development release for Drupal 11.4 on the 8.x-3.x branch; check the releases page for a stable tag that supports your core version. The module has had security advisories, including an access bypass between schemes sharing one bucket (SA-CONTRIB-2022-057), so install a patched release and subscribe to the advisories (see Updates, Composer and security advisories).

ddev composer require 'drupal/s3fs:^3.0'
ddev drush en s3fs -y
ddev drush s3fs:refresh-cache   # reads the bucket into the metadata cache

Credentials never go into configuration that is exported or committed. Keep them in the environment or in settings.php read from the environment:

<?php

// settings.php
$settings['s3fs.access_key'] = getenv('MERIDIAN_S3_ACCESS_KEY');
$settings['s3fs.secret_key'] = getenv('MERIDIAN_S3_SECRET_KEY');
$config['s3fs.settings']['bucket'] = getenv('MERIDIAN_S3_BUCKET') ?: 'CHANGE_ME';
$config['s3fs.settings']['region'] = 'eu-west-1';
// Serve public files from the bucket and keep the private ones behind Drupal.
$settings['s3fs.use_s3_for_public'] = TRUE;
$settings['s3fs.use_s3_for_private'] = TRUE;

The setting names above follow the module’s documentation; confirm them in the README of the release you install, because the contrib module versions its own options. Whatever the storage, private files in a bucket must stay private: do not make the bucket public and let Drupal stream the objects.

Scaling out

When a site runs on more than one machine, a plain local public:// and private:// directory breaks in typical ways: a file uploaded to server A returns 404 from server B, image derivatives are generated on one machine only, and a deployment that replaces the container deletes uploads. Choose one of these designs:

Approach How it works Trade-offs

Shared network file system

NFS or a managed file service mounted at the same path on every web server and referenced by file_public_path and file_private_path

Simple, no code change; latency and locking on busy sites, and the file service is a single point of failure

Object storage through a stream wrapper

S3 File System (or a vendor module) owns the schemes; the bucket is the shared store

Scales and survives disposable servers; needs credentials, a metadata cache and care with permissions

Shared volume in Kubernetes

A persistent volume claim with a read-write-many access mode mounted by every pod

Works with standard Drupal; the storage class must support concurrent writers

Sticky sessions with local disk

The load balancer sends a user to one server

Not a solution: files and derivatives still diverge, so avoid it

Three rules apply whatever you pick: keep the database and the files in step (a restored database needs the matching files), keep the private directory out of the web root and out of the code repository, and do not store cache or configuration in the shared tree. Temporary files (file_temp_path) can and should stay local to each machine, because they are scratch space; make sure the volume is large enough for large uploads. See Docker and containers and Deployment and CI/CD for the surrounding setup, and Performance and caching for caching in front of the file tier.

Troubleshooting

Symptom Likely cause and fix

"The specified file …​ could not be uploaded"

The target directory is missing or not writable; run prepareDirectory() or fix ownership; check upload_max_filesize and post_max_size

Private option missing in the field settings

file_private_path is not set or the directory does not exist

403 for a private file the user should see

A hook_file_download implementation returns -1, or the user cannot view the entity that references the file

Private file available to anonymous visitors

The file is attached to published content; add a download rule or an access-control module

Files disappear after six hours

The file was never made permanent or has no usage; call setPermanent() and file.usage->add()

Orphaned files fill the disk

Enable make_unused_managed_files_temporary and run cron, or clean up with a reviewed script

Plugin-not-found for file_validate_extensions

Code uses the pre-10.2 validator names; use the constraint IDs

Uploaded image works on one server only

Files are on local disks; use shared or object storage

Changed private path ignored

Rebuild caches

For log messages, the status report and debugging tools, see Logging, status report and troubleshooting.

What older books and tutorials get wrong

Material written for Drupal 10.0 and earlier, such as Drupal 10 Development Cookbook (2023), predates several changes that this page describes. Since 10.2 uploads are validated by constraint plugins and the file.validator service, and the file_validate_* functions no longer exist in Drupal 11. Since 10.3 the FileExists enum replaces the FileSystemInterface::EXISTS_* constants, which are scheduled for removal in 12.0.0. Since 10.2 the filename sanitisation event exists, and Drupal 12 changes the default behaviour for dangerous characters. See Where older books and tutorials are outdated for the site-wide list.