Guide to File Download in Magento 2: Code Examples and Explanation

While working as a Magento 2 developer, you may often encounter scenarios where you must enable file downloads on your Magento 2 store. Whether it’s a product downloads, user-generated content, or any other downloadable files, providing a seamless download experience is crucial. In this blog post, we will explore the process of enabling file downloads in Magento 2 by using code examples and explaining each piece of them.

Prepare the Downloadable File

Before diving into the code, ensure you have the file ready for download. Place the file in a directory accessible within your Magento 2 installation. For example, you can store it in the /pub/media directory, which is supposed to be in most cases when you want to enable your customers to download something directly from your website.

Create a Controller for the Download Action

I’m not going to dive into the nitty-gritty of how to create a module structure in this blog post. If you’re reading this, it is something I suppose you already know how to do, right? However, if you want me to create a tutorial on how to create a Magento 2 module structure, explaining all the pieces, leave a comment so I’ll know it.

Next, we’ll create a custom controller responsible for handling the file download action. Here’s an example of a custom controller.

<?php
/**
 * Copyright © MagedIn Technology. All rights reserved.
 */

declare(strict_types=1);

namespace MagedIn\MyModule\Controller\Dist;

use Exception;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\App\Response\Http\FileFactory;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\ResultInterface;

class Download implements HttpGetActionInterface
{
    /**
     * @param FileFactory $fileFactory
     */
    public function __construct(
        private readonly FileFactory $fileFactory
    ) {
    }

    /**
     * @return ResponseInterface|ResultInterface
     * @throws Exception
     */
    public function execute()
    {
        /** Replace with the actual file name. */
        $fileName = 'your_file_name.zip';

        /** Replace with the actual file path. */
        $filePath = 'path/to/your/file';

        $content = [
            'type' => 'filename',
            'value' => $filePath,
            'rm' => false,/** Remove the file from server after download (optional). */
        ];
        return $this->fileFactory->create($fileName, $content, DirectoryList::MEDIA);
    }
}

The game changer class in this controller is the following one:

\Magento\Framework\App\Response\Http\FileFactory

This file basically creates a response type that forces the download of the returned content because the response’s content type is ‘application/octet-stream’, which is a MIME type for binary files. When the browser receives this kind of content type, it doesn’t try to open the file even though it’s an image, it forces the download of the file to the user’s computer. This means that, even though your Magento 2 instance returns an image, it will be downloaded directly to your customer’s computer.

If you’re curious, like I am, here’s the anatomy of the FileFactory class for your appreciation. If you want to go straight to the point, you can just ignore this piece of code and jump to the next subtitle of this article.

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare(strict_types=1);

namespace Magento\Framework\App\Response\Http;

use Magento\Framework\App\Filesystem\DirectoryList;

/**
 * Class FileFactory serves to declare file content in response for download.
 *
 * @api
 */
class FileFactory
{
    /**
     * @var \Magento\Framework\App\ResponseInterface
     */
    protected $_response;

    /**
     * @var \Magento\Framework\Filesystem
     */
    protected $_filesystem;

    /**
     * @param \Magento\Framework\App\ResponseInterface $response
     * @param \Magento\Framework\Filesystem $filesystem
     */
    public function __construct(
        \Magento\Framework\App\ResponseInterface $response,
        \Magento\Framework\Filesystem $filesystem
    ) {
        $this->_response = $response;
        $this->_filesystem = $filesystem;
    }

    /**
     * Declare headers and content file in response for file download
     *
     * @param string $fileName
     * @param string|array $content set to null to avoid starting output, $contentLength should be set explicitly in
     *                              that case
     * @param string $baseDir
     * @param string $contentType
     * @param int $contentLength explicit content length, if strlen($content) isn't applicable
     * @throws \Exception
     * @throws \InvalidArgumentException
     * @return \Magento\Framework\App\ResponseInterface
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function create(
        $fileName,
        $content,
        $baseDir = DirectoryList::ROOT,
        $contentType = 'application/octet-stream',
        $contentLength = null
    ) {
        $dir = $this->_filesystem->getDirectoryWrite($baseDir);
        $isFile = false;
        $file = null;
        $fileContent = $this->getFileContent($content);
        if (is_array($content)) {
            if (!isset($content['type']) || !isset($content['value'])) {
                throw new \InvalidArgumentException("Invalid arguments. Keys 'type' and 'value' are required.");
            }
            if ($content['type'] == 'filename') {
                $isFile = true;
                $file = $content['value'];
                if (!$dir->isFile($file)) {
                    // phpcs:ignore Magento2.Exceptions.DirectThrow
                    throw new \Exception((string)new \Magento\Framework\Phrase('File not found'));
                }
                $contentLength = $dir->stat($file)['size'];
            }
        }
        $this->_response->setHttpResponseCode(200)
            ->setHeader('Pragma', 'public', true)
            ->setHeader('Cache-Control', 'must-revalidate, post-check=0, pre-check=0', true)
            ->setHeader('Content-type', $contentType, true)
            ->setHeader('Content-Length', $contentLength === null ? strlen((string)$fileContent) : $contentLength, true)
            ->setHeader('Content-Disposition', 'attachment; filename="' . $fileName . '"', true)
            ->setHeader('Last-Modified', date('r'), true);

        if ($content !== null) {
            $this->_response->sendHeaders();
            if ($isFile) {
                $stream = $dir->openFile($file, 'r');
                while (!$stream->eof()) {
                    // phpcs:ignore Magento2.Security.LanguageConstruct.DirectOutput
                    echo $stream->read(1024);
                }
            } else {
                $dir->writeFile($fileName, $fileContent);
                $file = $fileName;
                $stream = $dir->openFile($fileName, 'r');
                while (!$stream->eof()) {
                    // phpcs:ignore Magento2.Security.LanguageConstruct.DirectOutput
                    echo $stream->read(1024);
                }
            }
            $stream->close();
            flush();
            if (!empty($content['rm'])) {
                $dir->delete($file);
            }
        }
        return $this->_response;
    }

    /**
     * Returns file content for writing.
     *
     * @param string|array $content
     * @return string|array
     */
    private function getFileContent($content)
    {
        if (isset($content['type']) && $content['type'] === 'string') {
            return $content['value'];
        }

        return $content;
    }
}

Note: since the FileFactory class is noted with @api, you can use it because it won’t receive changes without backward compatibilization by the Magento Core Team.

Define the Route and URL

To access the custom controller, you need to define a route in your module’s etc/frontend/routes.xml file. Here’s an example.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="magedin_mymodule" frontName="my_download">
            <module name="MagedIn_MyModule" />
        </route>
    </router>
</config>

With the route defined, you can now access the download action using the URL:

http://yourstore.com/my_download/download/index

Provide Download Links

Now that the download functionality is ready, you can provide download links to users. Here’s a simple example of generating a download link in a template file:

<a href="<?= /** @noEscape */ $block->getUrl('my_download/download/index'); ?>"><?= __('Download File'); ?></a>

Note: Make sure to adjust the my_download with the actual front name you specified in the route configuration.

Enabling file downloads in Magento 2 involves creating a custom controller to handle the download action, defining a route, and generating download links. By following the steps outlined in this blog post, you can implement file downloads seamlessly on your Magento 2 store.

Remember to replace placeholders with actual file names and paths specific to your requirements. With the ability to offer file downloads, you can enhance the user experience and provide valuable resources to your customers.

Stay tuned for more Magento 2 tips and best practices to elevate your development skills!

Leave a comment