I want to make a form where a user can upload a file that will be saved in a predefined path in the private filesystem, replacing whatever was there before.

E.g. the destination file path could be 'private://mymodule/config.yml'.

The file, once uploaded, does not need to be a file entity, it can remain unmanaged. It just holds some global configuration or data.

Ideally there will be some validation of the file contents, before the destination file is overwritten.

What I found so far

I have not really found a complete example for this, and it seems I need to use some non-obvious steps. It seems I need file_save_upload(), which is a relic from Drupal 7. But I could be wrong.

E.g. I found this How can I upload and read an unmanaged file? but it is not really the same.

I might even answer this myself if I can figure it out :)

  • The linked example contains everything you need to know to build it yourself.
    – leymannx
    Commented Oct 12, 2022 at 22:02
  • I am going to post an answer with a code example.
    – donquixote
    Commented Oct 14, 2022 at 11:48

1 Answer 1


Here we go.


I did not come up with this myself, I just assembled ideas from elsewhere.

Code example

In this example I make it about an image file instead of a config file.

Create entry in mymodule.routing.yml

  path: '/admin/config/acme-logo'
    _form: '\Drupal\mymodule\Form\AcmeLogoForm'
    _title: 'Replace ACME logo'
    # This permission needs to be defined somewhere.
    _permission: 'manage acme logo'

Create the form


namespace Drupal\mymodule\Form;

use Drupal\Core\Extension\ExtensionPathResolver;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\File\FileUrlGeneratorInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\file\Entity\File;
use Symfony\Component\DependencyInjection\ContainerInterface;

 * Form to upload a custom acme-logo.png file.
class AcmeLogoForm extends FormBase {

  const CUSTOM_FILE_URI = 'private://path/to/acme-logo.png';

   * @var \Drupal\Core\File\FileUrlGeneratorInterface
  private FileUrlGeneratorInterface $fileUrlGenerator;

   * @var \Drupal\Core\File\FileSystemInterface
  private FileSystemInterface $fileSystem;

   * @var \Drupal\Core\Extension\ExtensionPathResolver
  private ExtensionPathResolver $extensionPathResolver;

   * Constructor.
   * @param \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator
   *   File url generator.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   File system.
  public function __construct(
    // Depending on PHP version we could use constructor property promotion.
    FileUrlGeneratorInterface $file_url_generator,
    FileSystemInterface $file_system,
    ExtensionPathResolver $extension_path_resolver
  ) {
    $this->fileUrlGenerator = $file_url_generator;
    $this->fileSystem = $file_system;
    $this->extensionPathResolver = $extension_path_resolver;

   * {@inheritdoc}
  public static function create(ContainerInterface $container) {
    return new static(

   * {@inheritdoc}
  public function getFormId() {
    return 'mymodule_acme_logo_form';

   * {@inheritdoc}
  public function buildForm(array $form, FormStateInterface $form_state) {

    $form['#_attributes'] = [
      'enctype' => 'multipart/form-data',

    // Show a link to the default file that is tracked in version control.
    $form['default_file_link'] = [
      'link' => [
        '#type' => 'link',
        '#url' => $this->fileUrlGenerator->generate($this->extensionPathResolver->getPath('module', 'mymodule') . '/acme-logo-default.png'),
        '#title' => 'default logo.png',
        '#attributes' => ['target' => '_blank'],
      '#theme_wrappers' => ['container'],

    // Show a download link for the custom file, if exists.
    if (is_file(self::CUSTOM_FILE_URI)) {
      $form['custom_file_link'] = [
        'link' => [
          '#type' => 'link',
          '#url' => $this->fileUrlGenerator->generate(self::CUSTOM_FILE_URI),
          '#title' => 'custom acme-logo.png',
          '#attributes' => ['target' => '_blank'],
        '#theme_wrappers' => ['container'],

    $form['file'] = [
      '#title' => $this->t('New custom acme-logo.png'),
      '#type' => 'file',

    $form['submit'] = [
      '#type'  => 'submit',
      '#value' => $this->t('Upload'),

    return $form;

   * {@inheritdoc}
  public function validateForm(array &$form, FormStateInterface $form_state): void {
    parent::validateForm($form, $form_state);

    // Create a temporary file entity based on the file form element.
    // @todo file_save_upload() might soon be deprecated.
    // Unfortunately https://www.drupal.org/node/2244513 was closed without providing an alternative to file_save_upload().
    // @todo Catch errors from this operation.
    // @todo Find a way to avoid creating temporary file entities.
    $tmp_file_entity = file_save_upload(
        'file_validate_extensions' => ['png'],

    if (!$tmp_file_entity instanceof File) {
      $form_state->setErrorByName('file', t('Upload failed.'));

    // @todo Validate file contents.

    // Keep the temporary file entity available for submit handler.
    $form_state->setValue('tmp_file', $tmp_file_entity);

   * {@inheritdoc}
  public function submitForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();

    /** @var \Drupal\file\Entity\File $tmp_file_entity */
    $tmp_file_entity = $values['tmp_file'];

    // Create parent directory, if it does not exist.
    // @todo Handle errors from this operation.
    $directory = dirname(self::CUSTOM_FILE_URI, 2);
      FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);

    // Copy the file as an unmanaged file in the private filesystem.
    // @todo Handle errors from this operation.


Allow download access

Because this is in private filesystem, we need to grant download access explicitly.

 * Implements hook_file_download().
function mymodule_file_download(string $uri) {
  if ($uri !== AcmeLogoForm::CUSTOM_FILE_URI) {
    // This is not the file we care about.
    return NULL;
  if (!\Drupal::currentUser()->hasPermission('view acme logo')) {
    // No access.
    return -1;
  // Set some headers to tell Drupal that access is granted.
  return [
    'Content-Type' => 'image/png',


The file_save_upload() always creates temporary file entities that "pollute" the database.

The same effect occurs when uploading logos or favicons in the core theme settings.

The file_managed database tables will fill up with entries for temporary files.

This seems to be just how it works, until a replacement for or alternative to file_save_upload() is found.

Perhaps Drupal\file\Upload\FileUploadHandler could be used directly, but this also creates file entities afaik.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.