Laravel ZipArchiveの使い方完全ガイド|複数ファイル圧縮・解凍・文字化け対策とS3連携

基本文法・構文ガイド実装・応用テクニック

Laravelで複数ファイルやフォルダのZIP圧縮・解凍を実装する際、PHP標準の ZipArchive クラスとLaravelの Storage ファサードを正しく組み合わせることで、高速かつ堅牢なファイル管理機能を実現できます。

実務のWebアプリケーション開発では、「管理画面から複数ユーザーの請求書PDFを一括ダウンロードさせたい」「ユーザーが投稿した写真アルバムをZIPにまとめて保存させたい」「アップロードされたZIPファイルを展開してインポートしたい」といった要件が日常的に発生します。

しかし、実際の開発現場では以下のような深刻なトラブルに直面することが少なくありません。

  • Windows環境での文字化け: MacやLinux上でUTF-8の日本語名ファイルを含めてZIPを作成すると、Windows標準のエクスプローラーで解凍した際にファイル名が壊れてしまう
  • メモリ枯渇(memory_limit超過): 大容量ファイルや大量の画像を一度にZIP化しようとすると、PHPプロセスのメモリ上限に達してサーバーエラー(500エラー)が発生する
  • AWS S3との連携の難しさ: S3などのクラウドストレージ上にある複数オブジェクトを、Webサーバーのディスク容量を圧迫せずにどうやってまとめてZIP化してダウンロードさせるか
  • セキュリティ脆弱性(Zip Slip): ユーザーからアップロードされたZIPを展開する際、パストラバーサル攻撃によってシステムファイルが上書きされるリスク
  • 一時ファイルの消し忘れ: 圧縮処理中に生成した一時ファイルがサーバー内に蓄積し、ディスクフルでシステム障害を引き起こす

この記事では、これらの実務課題をすべて解決するために、ZipArchiveの基礎構文から、Windows/Mac間の日本語ファイル名文字化け完全対策(CP932 / UTF-8)、大容量ストリーム配信、AWS S3ストレージ上のファイル一括ZIPダウンロード、Zip Slip脆弱性防御、AES-256暗号化パスワード設定まで、PHP 8.2+ および Laravel 10・11・12 でそのままコピペして動作する完全なコード付きで徹底解説します。

なお、Laravelにおけるファイルストレージの基本的な使い方については「Laravel Storageの基本と活用法:ファイル管理を効率化する秘訣」、ダウンロードレスポンス全般の設計については「Laravelファイルダウンロード完全実装|StreamDownload・S3連携・大容量ファイル対応」もあわせて参考にしてください。

  1. 【目的別】LaravelにおけるZIP処理パターン早見表
  2. 1. ZipArchiveの基本概念とLaravelでの準備
    1. PHP ext-zip 拡張モジュールの確認手順(php -m | grep zip)
    2. Laravelのストレージ構造(Storage::disk(‘local’))と一時作業ディレクトリの設計
  3. 2. 【実践】複数ファイルをまとめてZIP圧縮・ダウンロードさせる手順
    1. コントローラでのZipArchive初期化と新規作成(ZipArchive::CREATE | ZipArchive::OVERWRITE)
    2. addFile() と addFromString() の使い分け(DBから直接テキストをファイル化する手法)
    3. response()->download() と deleteFileAfterSend(true) による一時ZIPファイルの安全な自動削除
  4. 3. フォルダ(ディレクトリ)を再帰的に一括圧縮する方法
    1. RecursiveIteratorIterator と RecursiveDirectoryIterator を使ったディレクトリ走査
    2. ZIP内部の相対パスを正しく保持する実装パターン
  5. 4. アップロードされたZIPファイルの安全な解凍(展開)処理
    1. バリデーションルール(’file’ => ‘required|file|mimes:zip|max:10240’)
    2. extractTo() を使った展開処理とパストラバーサル(Zip Slip脆弱性)防止対策
  6. 5. 【実務の落とし穴】現場で直面する4大トラブルシューティング
    1. ① 日本語ファイル名の文字化け対策(Windows/Mac間のUTF-8 vs CP932変換: mb_convert_encoding)
      1. なぜWindowsで文字化けが起きるのか?
      2. 確実な解決策:2段階のアプローチ
    2. ② 大容量ファイル生成時のメモリ枯渇対策(memory_limit 超過防止・一時ファイル分割・ストリーム配信)
      1. メモリ枯渇を防ぐ3大鉄則
    3. ③ AWS S3上の複数オブジェクトをまとめてZIP化してダウンロードさせる実装手法
    4. ④ パスワード付き暗号化ZIPの作成(setPassword() / setEncryptionName())
      1. 暗号化実装のステップ
  7. 6. まとめ・関連記事リンク
    1. 実務チェックリスト
  8. 関連記事

【目的別】LaravelにおけるZIP処理パターン早見表

実務でZIP処理を導入する際は、要件(ファイル数、データ元、データサイズ)に応じて最適なアーキテクチャを選択する必要があります。以下の比較表を参考に、用途に適した実装パターンを選択してください。

ユースケース 主要メソッド / アプローチ メモリ消費量 一時ファイル 適した場面・特徴
複数ファイル一括圧縮 ZipArchive::addFile()
response()->download()
低〜中 要(自動削除推奨) ストレージ上に保存されている複数のPDFや画像ファイルをまとめてZIPダウンロードさせる標準パターン。
動的データ(DB/CSV)圧縮 ZipArchive::addFromString() DBから取得したデータをディスクに保存せず、メモリ上でテキスト化・CSV化して直接ZIP内に追加する。
フォルダ全体の再帰圧縮 RecursiveDirectoryIterator
ZipArchive::addEmptyDir()
低〜中 プロジェクトやディレクトリ内の階層構造・サブフォルダを完全に維持したまま丸ごとバックアップ・ZIP化する。
大容量ファイル・ストリーム response()->streamDownload()
または非同期キュー(Job)
極小(ストリーム時) 要(Job時) 数百MB〜数GB規模のデータ。同期処理でのタイムアウトや memory_limit 超過を防ぐ。
AWS S3上のファイルZIP化 Storage::disk('s3')->readStream()
一時ディスク中継
要(一時領域) S3バケット内の複数オブジェクトをダウンロードし、1つのZIPにまとめて配信する。
アップロードZIPの解凍 $zip->extractTo()
(Zip Slip検証必須)
展開先ディスク ユーザーがアップロードしたZIPを展開し、画像やCSVファイルをシステム内に取り込む。

1. ZipArchiveの基本概念とLaravelでの準備

PHP ext-zip 拡張モジュールの確認手順(php -m | grep zip)

ZipArchive は、PHPのコア拡張である ext-zip(内部的には libzip ライブラリ)によって提供される標準クラスです。Laravelのフレームワーク標準機能ではなくPHPの拡張モジュールであるため、動作環境にモジュールが正しくインストールされているかを確認する必要があります。

まずはターミナルから以下のコマンドを実行し、zip モジュールがロードされているか確認します。

# PHP CLIでzip拡張が読み込まれているか確認
php -m | grep -i zip

実行結果に zip と表示されれば準備完了です。もし何も表示されない場合は、ご利用のOSや実行環境に応じて以下のコマンドで拡張モジュールをインストールしてください。

# Ubuntu / Debian の場合
sudo apt-get update
sudo apt-get install -y php-zip php8.2-zip # ご利用のPHPバージョンに合わせる
sudo systemctl restart php8.2-fpm # Webサーバー/FPMを再起動

# Alpine Linux(Docker環境など)の場合
apk add --no-cache php-zip

# Red Hat / AlmaLinux / Rocky Linux の場合
sudo dnf install -y php-zip
sudo systemctl restart php-fpm

# macOS(Homebrew環境)の場合
# 通常はPHP本体に含まれていますが、PECL経由で追加する場合
pecl install zip

また、CLI環境だけでなくWebサーバー環境(PHP-FPM / Apache)でも有効化されているかを確認するため、コントローラーやテストコードで extension_loaded('zip') をチェックするヘルパーロジックを導入しておくと、本番デプロイ時の環境不備を即座に検知できます。

<?php

namespace AppHttpControllers;

use IlluminateHttpJsonResponse;

class SystemCheckController extends Controller
{
    /**
     * ZipArchiveの動作前提環境をチェック
     */
    public function checkZipEnvironment(): JsonResponse
    {
        $hasZip = extension_loaded('zip') && class_exists('ZipArchive');

        return response()->json([
            'zip_extension_loaded' => $hasZip,
            'libzip_version' => $hasZip ? phpversion('zip') : null,
            'php_version' => PHP_VERSION,
        ], $hasZip ? 200 : 500);
    }
}

Laravelのストレージ構造(Storage::disk(‘local’))と一時作業ディレクトリの設計

ZIPファイルを生成する際、多くの開発者が最初に悩むのが「生成中の一時ZIPファイルをどこに置くか」という問題です。選択肢としては主に以下の2つがあります。

  1. システムのテンポラリディレクトリ(sys_get_temp_dir() / /tmp: OS標準の一時領域。サーバー再起動時やOSの定期クリーンアップで自動消去されるが、Dockerコンテナ環境や共有ホスティングでは容量制限(tmpfs)に注意が必要。
  2. Laravelのローカルストレージ(storage/app/temp: Laravel管理下の永続ディスク領域。容量が大きく、ログやデバッグでの確認が容易だが、アプリケーション側で明示的に削除管理を行わないとディスクを圧迫する。

実務におけるベストプラクティスは、storage/app/temp/zips などの専用サブディレクトリを用意し、一意のファイル名を生成して安全に書き込み、ダウンロード完了直後に自動削除する」設計です。これにより、マルチプロセス環境でのファイル名衝突を防ぎ、パーミッションエラーを回避できます。

config/filesystems.php で一時ファイル用ディスクを定義するか、デフォルトの local ディスク上に専用ディレクトリを作成します。

// config/filesystems.php の disks 配下に追加する設定例
'temp' => [
    'driver' => 'local',
    'root' => storage_path('app/temp'),
    'throw' => false,
],

2. 【実践】複数ファイルをまとめてZIP圧縮・ダウンロードさせる手順

コントローラでのZipArchive初期化と新規作成(ZipArchive::CREATE | ZipArchive::OVERWRITE)

実務で最も頻出する「ストレージに保存されている複数のファイルをまとめてZIP化し、ユーザーのブラウザへ即座にダウンロードさせる」実装を解説します。

ZipArchive::open() を呼び出す際、フラグの指定方法が極めて重要です。

  • ZipArchive::CREATE: ZIPファイルが存在しない場合は新規作成する。
  • ZipArchive::OVERWRITE: 既存のファイルが存在する場合は上書きする。

この2つのビット論理和(ZipArchive::CREATE | ZipArchive::OVERWRITE)を指定することで、同名の一時ファイルが残っていた場合でも安全にクリーンな新規ZIPを作成できます。

以下は、PHP 8.2+ / Laravel 10・11・12 で動作する完全なコントローラーの実装例です。

<?php

declare(strict_types=1);

namespace AppHttpControllers;

use IlluminateHttpRequest;
use IlluminateSupportFacadesFile;
use IlluminateSupportFacadesStorage;
use IlluminateSupportStr;
use SymfonyComponentHttpFoundationBinaryFileResponse;
use ZipArchive;
use RuntimeException;

class ZipDownloadController extends Controller
{
    /**
     * 複数ファイルをまとめてZIP圧縮し、ダウンロードさせる
     *
     * @param Request $request
     * @return BinaryFileResponse
     * @throws RuntimeException
     */
    public function downloadArchive(Request $request): BinaryFileResponse
    {
        // 1. 圧縮対象のファイル一覧(Storage::disk('local') 相対パス)
        $targetFiles = [
            'invoices/2026/invoice_001.pdf' => '2026年01月度_請求書.pdf',
            'invoices/2026/invoice_002.pdf' => '2026年02月度_請求書.pdf',
            'reports/summary_report.xlsx'   => '年間サマリーレポート.xlsx',
        ];

        // 2. 一時作業ディレクトリの確保(storage/app/temp/zips)
        $tempDir = storage_path('app/temp/zips');
        if (! File::isDirectory($tempDir)) {
            File::makeDirectory($tempDir, 0755, true);
        }

        // 3. 一時ZIPファイルの一意パスを生成
        $zipFileName = 'archive_' . Str::uuid()->toString() . '.zip';
        $tempZipPath = $tempDir . DIRECTORY_SEPARATOR . $zipFileName;

        // 4. ZipArchiveの初期化とオープン
        $zip = new ZipArchive();
        $openResult = $zip->open($tempZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);

        if ($openResult !== true) {
            $errorMessage = $this->resolveZipErrorMessage($openResult);
            throw new RuntimeException("ZIPファイルの作成に失敗しました。詳細: {$errorMessage} (コード: {$openResult})");
        }

        try {
            $disk = Storage::disk('local');
            $filesAdded = 0;

            foreach ($targetFiles as $storagePath => $internalName) {
                if ($disk->exists($storagePath)) {
                    $absoluteFilePath = $disk->path($storagePath);
                    // addFile(実ファイルパス, ZIP内のファイル名)
                    $zip->addFile($absoluteFilePath, $internalName);
                    $filesAdded++;
                }
            }

            if ($filesAdded === 0) {
                $zip->close();
                @unlink($tempZipPath);
                throw new RuntimeException('圧縮対象となる有効なファイルが1件も存在しませんでした。');
            }

            // ZIPアーカイブの書き込み確定
            if (! $zip->close()) {
                throw new RuntimeException('ZIPアーカイブのクローズ(書き込み確定)処理に失敗しました。');
            }

            // 5. ダウンロードレスポンスの返却(送信完了後に一時ファイルを自動削除)
            $downloadOutputName = 'まとめダウンロード_' . now()->format('Ymd_His') . '.zip';

            return response()->download($tempZipPath, $downloadOutputName, [
                'Content-Type' => 'application/zip',
            ])->deleteFileAfterSend(true);

        } catch (Throwable $e) {
            // 例外発生時は一時ファイルが残らないよう確実に破棄する
            if (File::exists($tempZipPath)) {
                @unlink($tempZipPath);
            }
            throw $e;
        }
    }

    /**
     * ZipArchiveのオープンエラー定数をメッセージに変換
     */
    private function resolveZipErrorMessage(int $code): string
    {
        return match ($code) {
            ZipArchive::ER_EXISTS => '指定されたファイルが既に存在します。',
            ZipArchive::ER_INCONS => 'ZIPアーカイブの構造が破損しています。',
            ZipArchive::ER_INVAL  => '無効な引数が渡されました。',
            ZipArchive::ER_MEMORY => 'メモリ割り当てに失敗しました。',
            ZipArchive::ER_NOENT  => '指定されたファイルまたはディレクトリが存在しません。',
            ZipArchive::ER_NOZIP  => '有効なZIPアーカイブではありません。',
            ZipArchive::ER_OPEN   => 'ファイルを開けません(パーミッションを確認してください)。',
            ZipArchive::ER_READ   => '読み込みエラーが発生しました。',
            ZipArchive::ER_SEEK   => 'シークエラーが発生しました。',
            default               => '未知のエラーが発生しました。',
        };
    }
}

addFile() と addFromString() の使い分け(DBから直接テキストをファイル化する手法)

ZipArchive にファイルを追加する主要メソッドには、addFile()addFromString() の2種類があります。実務での使い分け基準は以下の通りです。

  • addFile(string $filepath, string $entryname): ディスク上に既に存在する物理ファイル(ストレージ上の画像、PDF、Excel等)を追加する場合に使用。メモリ消費が少なく、大容量ファイルに適しています。
  • addFromString(string $entryname, string $content): データベースの検索結果から動的に生成したCSV文字列や、JSONデータ、テキストログなどを、ディスクに一時保存することなく直接ZIP内に追加する場合に使用。

例えば、注文履歴やユーザー一覧をDBから取得してCSV化し、関連する納品書PDFと一緒に1つのZIPにまとめてダウンロードさせたい場合、addFromString() を活用するとディスクI/Oを大幅に削減できます。

// データベースから取得したデータをその場でCSV化してZIPに直接追加
$csvHeader = "ユーザーID,氏名,メールアドレス,登録日n";
$csvRows = User::select(['id', 'name', 'email', 'created_at'])
    ->limit(100)
    ->get()
    ->map(function ($u) {
        return sprintf('%d,"%s","%s",%s', $u->id, str_replace('"', '""', $u->name), $u->email, $u->created_at->format('Y-m-d'));
    })
    ->implode("n");

$csvContent = "xEFxBBxBF" . $csvHeader . $csvRows; // Excel文字化け防止用UTF-8 BOMを付加

// 一時ファイルを作らず、文字列データを直接ZIP内の 'ユーザー一覧.csv' として配置
$zip->addFromString('ユーザー一覧.csv', $csvContent);

response()->download() と deleteFileAfterSend(true) による一時ZIPファイルの安全な自動削除

Webサーバー上で一時ZIPファイルを生成した際、最も深刻な運用事故が「ダウンロード後に一時ファイルが削除されず、ディスク容量が100%に達してサーバーが停止する」トラブルです。

Laravelでは、Symfonyの BinaryFileResponse をラップした response()->download() メソッドチェーンに deleteFileAfterSend(true) を付与するだけで、クライアントへのHTTPストリーム送信が完了した直後に、PHPプロセスが自動的にそのファイルをアンリンク(削除)してくれます。

return response()
    ->download($tempZipPath, 'download.zip')
    ->deleteFileAfterSend(true);

ただし、注意が必要なのは「ZIP生成の途中で例外(Exception)が発生した場合や、クライアントが接続を切断したケース」です。deleteFileAfterSend(true) はレスポンスが正常に送信された場合にのみ作動するため、上記コントローラーコードのように try-catch-finally 構文で例外発生時にも必ず @unlink($tempZipPath) が実行されるフェイルセーフ構造を組むことが不可欠です。

3. フォルダ(ディレクトリ)を再帰的に一括圧縮する方法

RecursiveIteratorIterator と RecursiveDirectoryIterator を使ったディレクトリ走査

特定のフォルダ配下にあるサブフォルダや大量のファイルを、階層構造を保ったまま丸ごとZIP圧縮したい場合、PHPの標準SPLイテレータである RecursiveDirectoryIteratorRecursiveIteratorIterator を使用します。

このアプローチの利点は、フォルダ内のファイル一覧を一度に巨大な配列としてメモリに展開せず、1ファイルずつストリーム走査しながらZIPに追加できるため、ディレクトリ内に数千ファイルの階層が存在してもメモリ消費を最小限に抑えられる点です。

ZIP内部の相対パスを正しく保持する実装パターン

ディレクトリを圧縮する際によくある失敗が、「絶対パス(/var/www/app/storage/app/exports/...)のままZIPに追加してしまい、解凍したユーザーのPC上に深い不要なフォルダ階層が展開されてしまう」ケースです。

これを防ぐには、走査起点となるディレクトリのベースパスを除去し、ZIP内では起点フォルダからの相対パス(例: subfolder/document.pdf)として登録する必要があります。また、Windows環境とLinux環境のパス区切り文字(/)を / に正規化することも重要です。

以下は、ディレクトリの再帰圧縮を行う再利用可能なサービスクラスの完全な実装コードです。

<?php

declare(strict_types=1);

namespace AppServices;

use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use ZipArchive;
use RuntimeException;
use InvalidArgumentException;

class DirectoryZipService
{
    /**
     * 指定したディレクトリ配下を再帰的にZIP圧縮する
     *
     * @param string $sourceDirectoryPath 圧縮対象のディレクトリ絶対パス
     * @param string $destinationZipPath 出力先ZIPファイルの絶対パス
     * @return bool 成功時 true
     * @throws RuntimeException|InvalidArgumentException
     */
    public function zipDirectory(string $sourceDirectoryPath, string $destinationZipPath): bool
    {
        $realSourcePath = realpath($sourceDirectoryPath);

        if ($realSourcePath === false || ! is_dir($realSourcePath)) {
            throw new InvalidArgumentException("圧縮対象のディレクトリが存在しません: {$sourceDirectoryPath}");
        }

        $zip = new ZipArchive();
        $openStatus = $zip->open($destinationZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);

        if ($openStatus !== true) {
            throw new RuntimeException("ZIPアーカイブの作成に失敗しました (エラーコード: {$openStatus})");
        }

        try {
            // RecursiveDirectoryIterator::SKIP_DOTS で '.' と '..' を除外
            $directoryIterator = new RecursiveDirectoryIterator(
                $realSourcePath,
                RecursiveDirectoryIterator::SKIP_DOTS
            );

            // SELF_FIRST でディレクトリ自身を親から順に走査
            $iterator = new RecursiveIteratorIterator(
                $directoryIterator,
                RecursiveIteratorIterator::SELF_FIRST
            );

            $sourcePathLength = strlen($realSourcePath);

            /** @var SplFileInfo $fileInfo */
            foreach ($iterator as $fileInfo) {
                $filePath = $fileInfo->getRealPath();
                if ($filePath === false) {
                    continue;
                }

                // 起点ディレクトリからの相対パスを算出(先頭のスラッシュを除去)
                $relativePath = substr($filePath, $sourcePathLength + 1);

                // WindowsのバックスラッシュをZIP規格のフォワードスラッシュに統一
                $normalizedRelativePath = str_replace('\', '/', $relativePath);

                if ($fileInfo->isDir()) {
                    // 空ディレクトリの階層構造を保持するために空ディレクトリを追加
                    $zip->addEmptyDir($normalizedRelativePath);
                } elseif ($fileInfo->isFile()) {
                    // 通常ファイルをZIP内の相対パスで追加
                    $zip->addFile($filePath, $normalizedRelativePath);
                }
            }

            if (! $zip->close()) {
                throw new RuntimeException('ZIPファイルの書き込み・クローズ処理に失敗しました。');
            }

            return true;

        } catch (Throwable $e) {
            $zip->close();
            if (file_exists($destinationZipPath)) {
                @unlink($destinationZipPath);
            }
            throw $e;
        }
    }
}

4. アップロードされたZIPファイルの安全な解凍(展開)処理

バリデーションルール(’file’ => ‘required|file|mimes:zip|max:10240’)

ユーザーからZIPファイルをアップロードしてもらい、サーバー側で解凍して処理を行う機能(一括インポートなど)を実装する場合、最優先すべきは厳格なファイル検証です。

ZIPファイルはバイナリ形式であるため、拡張子の偽装(例: 実行可能スクリプト .php.sh の拡張子を .zip に書き換えたもの)や、数GBに膨れ上がる「ZIPボム(高圧縮のDoS攻撃)」を防止する必要があります。

Laravelの FormRequest を使用して、MIMEタイプとファイルサイズ上限を厳格に制限します。

<?php

declare(strict_types=1);

namespace AppHttpRequests;

use IlluminateFoundationHttpFormRequest;
use IlluminateValidationRulesFile;

class UploadZipRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * ZIPアップロードのバリデーションルール
     */
    public function rules(): array
    {
        return [
            'archive_file' => [
                'required',
                'file',
                // Laravel標準の mimes:zip または File::types() ルール
                File::types(['zip'])
                    ->max(20 * 1024), // 最大 20MB に制限
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'archive_file.required' => 'ZIPファイルを選択してください。',
            'archive_file.file'     => '有効なファイル形式を指定してください。',
            'archive_file.mimes'    => 'アップロードできるのはZIP形式のファイルのみです。',
            'archive_file.max'      => 'ZIPファイルのサイズは最大20MBまでです。',
        ];
    }
}

※環境によってはZIPのMIMEタイプが application/zip 以外に application/x-zip-compressedmultipart/x-zip と判定される場合があるため、mimes:zip での判定に加え、必要に応じて mimetypes:application/zip,application/x-zip-compressed を併用すると安全性が高まります。

extractTo() を使った展開処理とパストラバーサル(Zip Slip脆弱性)防止対策

ZipArchive::extractTo() は非常に簡潔にZIPを展開できる便利なメソッドですが、セキュリティ対策を講じずにそのまま呼び出すのは極めて危険です。

ZIPファイル内のファイル名には任意の文字列を含めることができるため、悪意のある攻撃者が ../../../../var/www/html/app/Http/Controllers/EvilController.php のような相対パスを含んだ細工済みZIPをアップロードした場合、本来の展開先ディレクトリを飛び越えてサーバー上のシステムファイルやソースコードが上書きされてしまいます。これが「Zip Slip(ジップスリップ)脆弱性」です。

Zip Slipを防ぐためには、extractTo() を実行する前に、ZIP内の全ファイルエントリをループ検査し、展開後の絶対パスが許可されたディレクトリ境界の内側に収まっているかを検証しなければなりません。

<?php

declare(strict_types=1);

namespace AppServices;

use ZipArchive;
use RuntimeException;
use IlluminateSupportFacadesFile;

class SafeZipExtractor
{
    /**
     * Zip Slip脆弱性を防御しながら安全にZIPを展開する
     *
     * @param string $zipFilePath ZIPファイルの絶対パス
     * @param string $destinationDir 展開先ディレクトリの絶対パス
     * @return array 展開されたファイル名の一覧
     * @throws RuntimeException パストラバーサル検知または展開エラー時
     */
    public function extractSafely(string $zipFilePath, string $destinationDir): array
    {
        $zip = new ZipArchive();
        $openResult = $zip->open($zipFilePath);

        if ($openResult !== true) {
            throw new RuntimeException("ZIPファイルを開けませんでした。コード: {$openResult}");
        }

        // 展開先ディレクトリの正規化絶対パスを取得
        if (! File::isDirectory($destinationDir)) {
            File::makeDirectory($destinationDir, 0755, true);
        }
        $realDestDir = realpath($destinationDir);

        if ($realDestDir === false) {
            $zip->close();
            throw new RuntimeException("展開先ディレクトリの絶対パスが解決できません: {$destinationDir}");
        }

        $extractedFiles = [];

        try {
            // 1. 全エントリの事前検証(Zip Slipの徹底防御)
            for ($i = 0; $i numFiles; $i++) {
                $entryName = $zip->getNameIndex($i);

                if ($entryName === false) {
                    continue;
                }

                // ヌルバイトインジェクション攻撃の排除
                if (str_contains($entryName, "")) {
                    throw new RuntimeException("不正なヌル文字がファイル名に含まれています: {$entryName}");
                }

                // パス区切り文字の統一
                $normalizedEntryName = str_replace('\', '/', $entryName);

                // 展開後のフルパスをシミュレート
                $targetPath = $realDestDir . DIRECTORY_SEPARATOR . ltrim($normalizedEntryName, '/');

                // ディレクトリトラバーサル(.. やリンク)のチェック
                // まだ存在しないファイルのため、親ディレクトリの realpath を検証
                $parentDir = dirname($targetPath);
                if (! File::isDirectory($parentDir)) {
                    File::makeDirectory($parentDir, 0755, true);
                }
                $realParentDir = realpath($parentDir);

                // 親ディレクトリが展開先ルートの内側に存在するかを前方一致で厳格に検証
                if ($realParentDir === false || ! str_starts_with($realParentDir, $realDestDir)) {
                    throw new RuntimeException("Zip Slip攻撃の疑いがある不正なパスを検知しました: {$entryName}");
                }

                $extractedFiles[] = $normalizedEntryName;
            }

            // 2. 安全性が完全に証明された後、展開を実行
            if (! $zip->extractTo($realDestDir)) {
                throw new RuntimeException("ZIPファイルの展開処理 (extractTo) に失敗しました。");
            }

            return $extractedFiles;

        } finally {
            $zip->close();
        }
    }
}

5. 【実務の落とし穴】現場で直面する4大トラブルシューティング

① 日本語ファイル名の文字化け対策(Windows/Mac間のUTF-8 vs CP932変換: mb_convert_encoding)

実務エンジニアがZIP作成で最も頭を抱えるのが、「MacやLinuxでZIPを作って配信すると、Windowsユーザーから『解凍したらファイル名が文字化けして読めない』とクレームが入る」問題です。

なぜWindowsで文字化けが起きるのか?

ZIPフォーマットの仕様上、エントリヘッダーの汎用ビットフラグ「第11ビット(bit 11)」が立っている場合のみ「UTF-8エンコーディング」として解釈されます。しかし、PHPの ZipArchive::addFile() はデフォルトでファイル名のバイト列をそのまま格納するため、bit 11 が正しく立たず、Windows標準のエクスプローラーはZIP内部のファイル名を日本語Windowsのデフォルト文字コードである「CP932(Shift-JIS互換)」とみなして解釈してしまい、UTF-8バイト列が激しく文字化けします。

確実な解決策:2段階のアプローチ

  1. PHP 8.x以降の標準フラグ指定(setEncryptionNamesetExternalAttributesName を使わずUTF-8指定):
    ZipArchive::setMtimeName() や最新の libzip では、UTF-8フラグを明示的にセットすることでモダンな解凍ソフトに対応します。
  2. Windowsクライアント向けにファイル名自体をCP932へ変換する(実務上の最強対策):
    Windows標準のエクスプローラーでの解凍を100%保証したい場合、ZIP内に追加するファイル名文字列を事前に mb_convert_encoding($name, 'SJIS-win', 'UTF-8') でCP932に変換して登録します。

実務では、リクエスト元のユーザーエージェント(User-Agent)を判定し、Windows環境からのアクセスの場合はCP932へ自動変換するハイブリッドなハンドリングが極めて効果的です。

<?php

declare(strict_types=1);

namespace AppServices;

use ZipArchive;
use IlluminateSupportStr;

class ZipFilenameEncoder
{
    /**
     * クライアントOSに合わせてZIP内のファイル名エンコーディングを最適化する
     *
     * @param ZipArchive $zip
     * @param string $sourceFilePath 実際のファイルパス
     * @param string $utf8Filename 元の日本語ファイル名(UTF-8)
     * @param bool $isWindowsClient Windows端末かどうか
     * @return bool
     */
    public function addFileWithProperEncoding(
        ZipArchive $zip,
        string $sourceFilePath,
        string $utf8Filename,
        bool $isWindowsClient = false
    ): bool {
        if ($isWindowsClient) {
            // Windows標準エクスプローラー用にCP932(SJIS-win)に変換
            // 丸数字や特殊記号(波ダッシュなど)の文字化けを防ぐため 'SJIS-win' を指定
            $encodedFilename = mb_convert_encoding($utf8Filename, 'SJIS-win', 'UTF-8');
            return $zip->addFile($sourceFilePath, $encodedFilename);
        }

        // Mac / Linux / モダン解凍ソフト向け(UTF-8)
        return $zip->addFile($sourceFilePath, $utf8Filename);
    }

    /**
     * User-AgentからWindowsクライアントかどうかを判定
     */
    public function isWindowsUserAgent(?string $userAgent): bool
    {
        if (empty($userAgent)) {
            return false;
        }

        return Str::contains($userAgent, ['Windows', 'Win32', 'Win64']);
    }
}

② 大容量ファイル生成時のメモリ枯渇対策(memory_limit 超過防止・一時ファイル分割・ストリーム配信)

数十MB〜数GBに達するファイル群をZIP化する際、安易に Storage::get()file_get_contents() でメモリ上に読み込んで addFromString() してしまうと、一瞬でPHPの memory_limit(通常128M〜512M)を食い潰してプロセスが強制終了(Fatal Error: Allowed memory size of X bytes exhausted)します。

メモリ枯渇を防ぐ3大鉄則

  1. 巨大データは必ずディスク一時ファイルを経由して addFile() を使う: addFile() はZIPのクローズ時まで実データをメモリに読み込みません。
  2. レスポンスには response()->streamDownload() を活用する: 出来上がったZIPファイルを file_get_contents() でレスポンスに渡すのではなく、読み込みバッファ(8KB〜64KB単位)で順次クライアントにストリーム送出します。
  3. 数百MB以上の場合は「非同期キュー(Job)」へ逃がす: Webリクエストのタイムアウト(通常30〜60秒)を回避するため、Laravel Queueでバックグラウンド生成し、完了後にS3署名付きURLをメール通知します。

以下は、大容量ZIPを省メモリにストリーム配信するコントローラーの実装コードです。

<?php

declare(strict_types=1);

namespace AppHttpControllers;

use SymfonyComponentHttpFoundationStreamedResponse;
use ZipArchive;
use RuntimeException;

class StreamZipController extends Controller
{
    /**
     * 大容量ZIPをメモリを圧迫せずにストリーム出力
     */
    public function streamLargeZip(): StreamedResponse
    {
        return response()->streamDownload(function () {
            // 一時ファイルを作成してZIPを構成
            $tempZip = tempnam(sys_get_temp_dir(), 'stream_zip_');
            $zip = new ZipArchive();
            $zip->open($tempZip, ZipArchive::CREATE | ZipArchive::OVERWRITE);

            // 例: 大量ファイルを順次追加
            // (実務ではクエリやストレージから逐次取得)
            for ($i = 1; $i addFromString("report_{$i}.txt", "レポート本文データ #{$i}n生成日時: " . now()->toDateTimeString());
            }

            $zip->close();

            // 64KB単位のチャンクで出力バッファへストリーム転送
            $handle = fopen($tempZip, 'rb');
            if ($handle === false) {
                @unlink($tempZip);
                throw new RuntimeException('一時ZIPファイルを開けませんでした。');
            }

            try {
                while (! feof($handle)) {
                    echo fread($handle, 65536); // 64KBずつ読み出し
                    flush(); // 出力バッファを強制フラッシュして即時クライアントへ送信
                }
            } finally {
                fclose($handle);
                @unlink($tempZip); // 送信完了後に確実にディスクから破棄
            }
        }, 'reports_archive.zip', [
            'Content-Type' => 'application/zip',
            'Cache-Control' => 'no-cache, no-store, must-revalidate',
        ]);
    }
}

③ AWS S3上の複数オブジェクトをまとめてZIP化してダウンロードさせる実装手法

近年のWebシステムでは、画像や帳票などの静的アセットはローカルディスクではなく Amazon S3(またはCloudflare R2, MinIOなど) に格納されているのが標準的です。

S3上のファイルを複数まとめてZIPにする場合、「S3からローカルの一時ディレクトリへストリーム中継してZIPを組み立てる」パイプライン設計が必須となります。ファイル全体をメモリにロードするのではなく、Storage::disk('s3')->readStream() を活用してディスク上に直接ストリーム保存することで、サーバーのメモリ消費量を最小限に抑えられます。

<?php

declare(strict_types=1);

namespace AppServices;

use IlluminateSupportFacadesFile;
use IlluminateSupportFacadesStorage;
use IlluminateSupportStr;
use ZipArchive;
use RuntimeException;

class S3ZipArchiveService
{
    /**
     * S3上の複数オブジェクトを取得して1つのZIPにまとめて一時パスを返す
     *
     * @param array $s3Files ['s3/path/file.pdf' => 'ダウンロード時のファイル名.pdf']
     * @return string 生成された一時ZIPファイルの絶対パス
     * @throws RuntimeException
     */
    public function createZipFromS3(array $s3Files): string
    {
        $s3Disk = Storage::disk('s3');
        $tempWorkingDir = storage_path('app/temp/s3_zip_' . Str::uuid()->toString());
        File::makeDirectory($tempWorkingDir, 0755, true);

        $tempZipPath = storage_path('app/temp/archive_' . Str::uuid()->toString() . '.zip');
        $zip = new ZipArchive();

        if ($zip->open($tempZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            File::deleteDirectory($tempWorkingDir);
            throw new RuntimeException('一時ZIPアーカイブのオープンに失敗しました。');
        }

        try {
            $downloadedCount = 0;

            foreach ($s3Files as $s3Path => $localZipEntryName) {
                if (! $s3Disk->exists($s3Path)) {
                    continue;
                }

                // S3オブジェクトを一時作業フォルダへストリーム保存
                $localTempFilePath = $tempWorkingDir . DIRECTORY_SEPARATOR . Str::random(16) . '.tmp';
                $readStream = $s3Disk->readStream($s3Path);
                $writeStream = fopen($localTempFilePath, 'wb');

                if ($readStream === false || $writeStream === false) {
                    continue;
                }

                stream_copy_to_stream($readStream, $writeStream);
                fclose($readStream);
                fclose($writeStream);

                // 一時ファイルをZIPに追加
                $zip->addFile($localTempFilePath, $localZipEntryName);
                $downloadedCount++;
            }

            if ($downloadedCount === 0) {
                throw new RuntimeException('S3上に有効なファイルが1件も存在しませんでした。');
            }

            $zip->close();
            return $tempZipPath;

        } catch (Throwable $e) {
            $zip->close();
            if (file_exists($tempZipPath)) {
                @unlink($tempZipPath);
            }
            throw $e;
        } finally {
            // S3から中継ダウンロードした一時ファイル群をフォルダごと完全消去
            File::deleteDirectory($tempWorkingDir);
        }
    }
}

S3連携の初期設定やIAMポリシー設計の基礎については「Laravel×S3画像アップロード完全ガイド|Storageファサード設定・Flysystem導入から公開URL取得・IAM権限まで徹底解説」で詳しく解説しています。

④ パスワード付き暗号化ZIPの作成(setPassword() / setEncryptionName())

個人情報や機密性の高い契約書・明細データなどを配信する場合、パスワードによる暗号化が求められるケースがあります。PHP 7.2以降の ZipArchive は、強力な AES-256(Advanced Encryption Standard 256bit)暗号化 に標準対応しています。

暗号化実装のステップ

  1. $zip->setPassword('パスワード文字列') を呼び出してグローバルパスワードを設定する。
  2. $zip->addFile() または $zip->addFromString() でファイルを追加する。
  3. 追加したファイルごとに $zip->setEncryptionName($entryName, ZipArchive::EM_AES_256) を呼び出して暗号化方式を適用する(※setPassword() を呼ぶだけでは暗号化されません)。
<?php

declare(strict_types=1);

namespace AppServices;

use ZipArchive;
use RuntimeException;

class EncryptedZipService
{
    /**
     * パスワード保護(AES-256)されたZIPファイルを生成する
     *
     * @param string $destinationZipPath 出力先パス
     * @param array $files ['実ファイルパス' => 'ZIP内名称']
     * @param string $password 暗号化パスワード
     * @return bool
     */
    public function createPasswordProtectedZip(
        string $destinationZipPath,
        array $files,
        string $password
    ): bool {
        $zip = new ZipArchive();
        if ($zip->open($destinationZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException('暗号化ZIPファイルの作成に失敗しました。');
        }

        // 1. パスワードの設定
        if (! $zip->setPassword($password)) {
            $zip->close();
            throw new RuntimeException('ZIPパスワードの設定に失敗しました。');
        }

        try {
            foreach ($files as $sourcePath => $entryName) {
                if (file_exists($sourcePath)) {
                    // 2. ファイルの追加
                    $zip->addFile($sourcePath, $entryName);

                    // 3. ファイルごとにAES-256暗号化を明示的に適用
                    $zip->setEncryptionName($entryName, ZipArchive::EM_AES_256);
                }
            }

            return $zip->close();

        } catch (Throwable $e) {
            $zip->close();
            if (file_exists($destinationZipPath)) {
                @unlink($destinationZipPath);
            }
            throw $e;
        }
    }
}

※従来の「ZipCrypto(従来の標準暗号化)」は計算リソースが少ない古いPCでも解凍できる利点がありましたが、現在では既知の攻撃手法(平文既知攻撃)により容易に突破される脆弱性があるため、実務システムでは必ず ZipArchive::EM_AES_256 を指定してください。

6. まとめ・関連記事リンク

Laravelにおける ZipArchive を用いたファイル圧縮・解凍処理は、単にファイルをまとめるだけでなく、本番運用の安全性・パフォーマンス・ユーザー体験(UX)を両立させる設計が不可欠です。

実務チェックリスト

  • 環境準備: php -m | grep zipext-zip が有効化されているか
  • エラーハンドリング: $zip->open() の整数エラーコード(ER_*)を判定しているか
  • 一時ファイルの確実な消去: response()->download()->deleteFileAfterSend(true) と例外時の try-finally による二重防御を行っているか
  • 文字化け対策: Windowsユーザー向けに SJIS-win (CP932) への適切な変換を行っているか
  • セキュリティ(Zip Slip防止): 解凍前に全エントリの相対パスを検証し、ディレクトリトラバーサルを防いでいるか
  • 大容量・クラウドストレージ: S3からのストリーム中継や streamDownload()、非同期Jobへの切り出しを行っているか

ファイルストレージ全体の設計や例外処理の共通化については、以下の関連記事もぜひあわせてご覧ください。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント