Laravel Livewire Tutorial: Add ZIP Download Feature to Your File Explorer

Learn how to add a ‘Download All as ZIP’ feature to your Laravel Livewire file explorer. Compress all files in a folder – grouped by type – into a single ZIP using Laravel Storage and PHP ZipArchive.

When I first built a file explorer using Laravel Livewire, browsing and viewing documents worked great — but there was one feature missing: a simple way to download everything at once.

In this article, I’ll show you how I added a “Download” button that compresses every file in a folder (even grouped by document type) into a single ZIP, ready for instant download. Using just a few lines of code with Laravel’s Storage facade and PHP’s ZipArchive, you’ll learn how to turn your file explorer into a fully functional, production-ready document manager.

Step 1. Preparing Your Storage

All documents are stored inside:

storage/app/public/documents/

and referenced in the database via a file_path column.

Make sure your storage is linked:

php artisan storage:link

Step 2. Update the Livewire Component

Open your ApplicationExplorer Livewire component and add the following import statements at the top:

use Illuminate\Support\Facades\Storage;
use ZipArchive;

Then add the new method:

public function downloadAppDocuments($appId)
{
    $app = \App\Models\Application::with('documents')->findOrFail($appId);

    if ($app->documents->isEmpty()) {
        session()->flash('message', 'No documents to download for this application.');
        return;
    }

    $zipFileName = 'application_' . $app->id . '_documents.zip';
    $zipPath = storage_path('app/temp/' . $zipFileName);

    // Make sure temp directory exists
    if (!file_exists(storage_path('app/temp'))) {
        mkdir(storage_path('app/temp'), 0777, true);
    }

    $zip = new ZipArchive;
    if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) {
        foreach ($app->documents as $doc) {
            // Access the public disk
            if (Storage::disk('public')->exists($doc->file_path)) {
                // Add subfolder by document type (optional)
                $folder = $doc->type ? $doc->type . '/' : '';
                $zip->addFile(
                    Storage::disk('public')->path($doc->file_path),
                    $folder . basename($doc->file_path)
                );
            }
        }
        $zip->close();
    }

    return response()->download($zipPath)->deleteFileAfterSend(true);
}

How It Works

  1. The method loads the selected application with all its documents.
  2. It creates a temporary ZIP file in storage/app/temp.
  3. It loops through each document, checking if it exists on the public disk.
  4. Each document is added to the ZIP file, optionally organized into subfolders based on document type.
  5. Finally, Laravel’s response()->download() streams the file to the user and deletes it after sending.

Step 3. Add the “Download” Button in the UI

In your Livewire Blade view (application-explorer.blade.php), add this button next to the search bar:

<button wire:click="downloadAppDocuments({{ $selectedApp->id }})" 
        class="btn btn-success">
    Download
</button>

Or, if you prefer to show it beside each folder in the sidebar, update your partial (sidebar-app.blade.php):

<button wire:click.stop="downloadAppDocuments({{ $app->id }})"
        class="btn btn-sm btn-outline-primary me-2"
        title="Download all documents">
    ⬇️
</button>

The .stop modifier ensures this button doesn’t also trigger folder selection.

Step 4. Organizing Documents by Type

If you store files based on type (like invoices, reports, letters), your upload logic should look like this:

$file->store("applications/{$applicationId}/" . strtolower($type), 'public');

Then, in the ZIP creation logic, the $doc->type value creates the corresponding subfolder inside the ZIP.

Example folder structure in ZIP:

application_3_documents.zip
├── invoices/
│   ├── invoice1.pdf
│   └── invoice2.pdf
├── reports/
│   └── report1.pdf
└── letters/
    └── letter1.pdf

Step 5. Handling Missing Files Gracefully

If a document record exists but the file is missing from storage, Storage::disk('public')->exists() safely skips it.

You can also log missing files for auditing:

if (!Storage::disk('public')->exists($doc->file_path)) {
    \Log::warning("Missing file: {$doc->file_path}");
    continue;
}

Step 6. Testing the Download

  1. Select a folder in your explorer.
  2. Click “Download All”.
  3. A .zip file should download automatically.
  4. Extract it — files are grouped by type (if configured).

If you see “file not found” messages, verify:

  • The file_path in the database points to documents/...
  • The disk is correctly set in .env:
FILESYSTEM_DISK=public

Conclusion

And just like that, your Laravel Livewire file explorer now has the power to download everything in one click. No extra packages, no complicated setup — just smart use of Laravel’s Storage facade and PHP’s native ZipArchive.

This simple addition dramatically improves user experience, especially when handling large sets of documents. It also showcases how flexible Livewire can be for real-time, interactive Laravel apps.

From here, you can expand even further: add selective downloads, handle nested folders recursively, or generate temporary signed URLs for secure file sharing. With this foundation in place, your file explorer isn’t just a viewer anymore — it’s a complete, dynamic file management solution.

How I Built a Full File Explorer in Laravel Livewire with Search

Learn how to create a complete file explorer in Laravel using Livewire and Bootstrap – with folders, subfolders, and document search. Step-by-step tutorial with code.

Have you ever wanted to give your Laravel app a professional, desktop-like file explorer – with folders, nested subfolders and document search?

In this tutorial, I’ll walk you through building full file explorer using Laravel Livewire and Bootstrap 5. We’ll create a clean two-pane layout: the left sidebar displays your folder structure (applications and subfolders), while the right pane dynamically lists documents with instant search and filtering.

By the end, you’ll have a fully functional, real-time file manager where users can browse folders, search documents by name and group them by type – all powered by Livewire’s reactivity and Laravel’s filesystem magic.

What We’re Building

We’ll create a two-pane interface like a typical file explorer:

  • Left sidebar → list of applications/folders and nested subfolders.
  • Right pane → list of documents for the selected application.
  • Features:
    • Real-time search
    • Subfolder navigation
    • Document type–based organization

Step 1. Setting Up the Database

We’ll use two tables: applications and documents. applications table holds our folders and subfolders, while documents table holds all documents with name and path with application relation.

Schema::create('applications', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->foreignId('parent_id')->nullable()->constrained('applications')->cascadeOnDelete();
    $table->timestamps();
});

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->foreignId('application_id')->constrained()->cascadeOnDelete();
    $table->string('title');
    $table->string('file_path');  // e.g. "documents/report.pdf"
    $table->string('type')->nullable(); // e.g. "invoice", "report", "letter"
    $table->timestamps();
});

Step 2. Eloquent Relationships

Define relationships in models as follows,

// Application.php
class Application extends Model
{
    protected $fillable = ['name', 'parent_id'];

    public function parent()
    {
        return $this->belongsTo(Application::class, 'parent_id');
    }

    public function children()
    {
        return $this->hasMany(Application::class, 'parent_id');
    }

    public function documents()
    {
        return $this->hasMany(Document::class);
    }
}

// Document.php
class Document extends Model
{
    protected $fillable = ['application_id', 'title', 'file_path', 'type'];

    public function application()
    {
        return $this->belongsTo(Application::class);
    }
}

Step 3. The Livewire Component

Run the following command to create the livewire component:

php artisan make:livewire ApplicationExplorer

It will create 2 files as follows,

  • app/Http/Livewire/ApplicationExplorer.php for file explorer processing.
  • resources/views/livewire/application-explorer.blade.php for file explorer view.

Define all Processes

Open app/Http/Livewire/ApplicationExplorer.php and add the following code:

namespace App\Http\Livewire;

use Livewire\Component;
use App\Models\Application;
use App\Models\Document;
use Livewire\WithPagination;
use Illuminate\Support\Facades\Storage;
use ZipArchive;

class ApplicationExplorer extends Component
{
    use WithPagination;
    protected $paginationTheme = 'bootstrap';

    public $selectedAppId = null;
    public $search = '';
    public $filterType = '';

    public function updatingSearch()
    {
        $this->resetPage();
    }

    public function selectApp($appId)
    {
        $this->selectedAppId = $appId;
        $this->reset(['search', 'filterType']);
        $this->resetPage();
    }

    public function render()
    {
        $applications = Application::with('children')->whereNull('parent_id')->get();

        $selectedApp = $this->selectedAppId ? Application::find($this->selectedAppId) : null;

        $documents = collect();
        if ($selectedApp) {
            $documents = Document::where('application_id', $this->selectedAppId)
                ->when($this->filterType, fn($q) => $q->where('type', $this->filterType))
                ->when($this->search, fn($q) => $q->where('title', 'like', "%{$this->search}%"))
                ->paginate(10);
        }

        return view('livewire.application-explorer', [
            'applications' => $applications,
            'selectedApp'  => $selectedApp,
            'documents'    => $documents,
        ]);
    }
}

Update the View

To update the view, open resources/views/livewire/application-explorer.blade.php and add the below code,

<div class="container-fluid vh-100">
    <div class="row h-100">
        {{-- Sidebar --}}
        <div class="col-3 border-end bg-light p-0 overflow-auto">
            <div class="p-3 border-bottom"><h5 class="mb-0">Applications</h5></div>
            <ul class="list-group list-group-flush">
                @foreach($applications as $app)
                    @include('livewire.partials.sidebar-app', ['app' => $app, 'level' => 0])
                @endforeach
            </ul>
        </div>

        {{-- Right Pane --}}
        <div class="col-9 p-4">
            @if($selectedApp)
                <div class="d-flex justify-content-between align-items-center mb-3">
                    <h4 class="mb-0">Documents in "{{ $selectedApp->name }}"</h4>
                    <div class="d-flex gap-2 align-items-center" style="width:50%;">
                        <select class="form-select w-auto" wire:model="filterType">
                            <option value="">All Types</option>
                            <option value="invoice">Invoice</option>
                            <option value="report">Report</option>
                            <option value="letter">Letter</option>
                        </select>
                        <input type="text" class="form-control" placeholder="Search..." wire:model.debounce.300ms="search">
                    </div>
                </div>

                @if($documents->count())
                    <div class="table-responsive">
                        <table class="table table-striped table-bordered align-middle">
                            <thead class="table-dark">
                                <tr>
                                    <th>#</th>
                                    <th>Document</th>
                                    <th>Type</th>
                                    <th>Created</th>
                                    <th>Actions</th>
                                </tr>
                            </thead>
                            <tbody>
                                @foreach($documents as $i => $doc)
                                    <tr>
                                        <td>{{ $i + 1 }}</td>
                                        <td>📄 {{ $doc->title }}</td>
                                        <td>{{ $doc->type ?? '-' }}</td>
                                        <td>{{ $doc->created_at->format('d M Y') }}</td>
                                        <td><button class="btn btn-sm btn-primary">View</button></td>
                                    </tr>
                                @endforeach
                            </tbody>
                        </table>
                    </div>

                    {{ $documents->links() }}
                @else
                    <div class="text-muted fst-italic">No documents found.</div>
                @endif
            @else
                <div class="text-muted fst-italic">Select an application to view documents.</div>
            @endif
        </div>
    </div>
</div>

For sidebar, create a new file at resources/views/livewire/partials/sidebar-app.blade.php and add the following code,

<li class="list-group-item p-0 d-flex justify-content-between align-items-center">
    <button wire:click="selectApp({{ $app->id }})"
            class="btn flex-grow-1 text-start px-3 py-2 {{ $selectedAppId === $app->id ? 'btn-secondary' : 'btn-light' }}"
            style="padding-left: {{ 12 + ($level * 15) }}px;">
        📁 <span class="ms-2">{{ $app->name }}</span>
    </button>
</li>

@if($app->children->count())
    <ul class="list-group list-group-flush">
        @foreach($app->children as $child)
            @include('livewire.partials.sidebar-app', ['app' => $child, 'level' => $level + 1])
        @endforeach
    </ul>
@endif

Step 4. File Storage Notes

Documents are uploaded to storage/app/public/documents/.... So, make sure that your public link exists:

php artisan storage:link

Always use:

Storage::disk('public')->exists($path);
Storage::disk('public')->path($path);

This ensures correct behavior since your files are under the public disk.

Step 5. Directory Structure by Document Type

When uploading new documents, if you want to make a directory structure based on document types, you can follow this approach:

$file->store("documents/{$applicationId}/" . strtolower($type), 'public');

This automatically creates folders like:

/storage/app/public/documents/3/invoices/file.pdf

Final Result

  • Left Sidebar: Displays all applications + nested subfolders.
  • Right Pane: Shows searchable, paginated, filterable document table.

Conclusion

And there you have it – a complete file explorer built with Laravel Livewire, featuring a responsive sidebar, real-time search and type-based organization.

This approach not only showcases the power of Livewire for dynamic interfaces but also demonstrates how easily Laravel’s Storage, and Eloquent relationships can come together to create a feature-rich, production-ready document management system.

From here, you can take it even further – add drag-and-drop uploads, access control, file previews, download all files in single zip with complete folder structure or even integrate third-party storage like AWS S3. With this foundation, your Laravel app can handle documents as efficiently as any modern file manager.

How to Reset a Forgotten phpMyAdmin Password

Forgot your phpMyAdmin password? Follow this simple guide to reset your MySQL root or user password and regain access to phpMyAdmin on localhost or server.

Forgetting your phpMyAdmin password can be frustrating, especially when you’re in the middle of working on a critical project. Fortunately, resetting the password is a straightforward process. This guide walks you through the steps needed to reset your phpMyAdmin (MySQL/MariaDB) root password on a local or remote server.

Step-by-Step Guide to Reset phpMyAdmin Root Password

Step 1: Stop the MySQL Service

Before making any changes, stop the MySQL or MariaDB service.

sudo systemctl stop mysql

Step 2: Start MySQL in Safe Mode

Run MySQL in safe mode without password authentication.

sudo mysqld_safe --skip-grant-tables &

This allows you to log in without needing a password.

Step 3: Log in to MySQL

Now log into MySQL as the root user:

mysql -u root

You’ll be taken directly to the MySQL shell.

Step 4: Change the Root Password

Run the following commands to change the root password.

For MySQL 5.7+ or MariaDB 10.1+:

FLUSH PRIVILEGES;
ALTER USER 'root'@'localhost' IDENTIFIED BY 'your_new_password';

For older versions:

USE mysql;
UPDATE user SET password=PASSWORD('your_new_password') WHERE User='root';
FLUSH PRIVILEGES;

Replace 'your_new_password' with your desired password.

Step 5: Stop MySQL Safe Mode and Restart Normally

Press Ctrl+C to stop the MySQL safe mode process (if it’s running in foreground), or kill it using:

sudo killall -9 mysqld_safe
sudo killall -9 mysqld

Then start the service again:

sudo systemctl start mysql

Step 6: Test Login to phpMyAdmin

Go to http://localhost/phpmyadmin or your server’s phpMyAdmin URL and log in using:

  • Username: root
  • Password: the new password you just set

If successful, you’re good to go!

Tips for Better Security

  • Avoid using the root account for daily tasks. Create a separate user with limited privileges.
  • Use strong, unique passwords and store them securely using a password manager.
  • Regularly update MySQL/MariaDB and phpMyAdmin for security patches.

Common Issues & Fixes

Issue: Access denied for user 'root'@'localhost'
Fix: Ensure you flushed privileges and restarted the MySQL server after changing the password.

Issue: phpMyAdmin login loop
Fix: Check config.inc.php in your phpMyAdmin directory. Ensure $cfg['Servers'][$i]['auth_type'] is set to 'cookie'.

Conclusion

Resetting a forgotten phpMyAdmin password isn’t difficult when you follow the correct steps. Always remember to restart the MySQL service after resetting the password and ensure your configuration files are properly set. Keeping your credentials secure and using non-root accounts for regular usage can further enhance your database security.

How to Use TinyMCE with Livewire and Sortable.js in a Dynamic Form Builder

Learn how to integrate TinyMCE with Livewire and Sortable.js in a dynamic form builder. Build interactive forms with drag-and-drop and rich text editing in Laravel.

When building a drag-and-drop form builder with Laravel Livewire, a common challenge is integrating rich text editors like TinyMCE while also supporting sortable components and repeaters.

Checkout my article to build dynamic form builder using Laravel Livewire here.

This guide walks you through a working setup where:

  • Components can be added dynamically.
  • Fields can include TinyMCE editors.
  • Components can be dragged and reordered using Sortable.js.
  • TinyMCE properly initializes and syncs data back to Livewire.

Step 1: Create Your TinyMCE Field Partial

Inside your Blade view for form fields, create a partial for TinyMCE:

<div wire:ignore>
    <textarea
        id="{{ $editorId }}"
        class="tinymce"
        data-model="{{ $modelPath }}"
    >{{ is_string($value) ? $value : '' }}</textarea>
</div>
  • wire:ignore ensures Livewire doesn’t overwrite the editor.
  • data-model stores the Livewire binding path for syncing.

Step 2: Add TinyMCE + Sortable.js JavaScript

Include TinyMCE and Sortable.js in your layout:

<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>

Now initialize editors and handle drag-and-drop:

<script>
    function initTinyMCEEditors() {
        document.querySelectorAll('textarea.tinymce').forEach((el) => {
            if (!el.id || tinymce.get(el.id)) return;

            tinymce.init({
                selector: `#${el.id}`,
                menubar: false,
                plugins: 'link lists code image table',
                toolbar: 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image code',
                setup: function (editor) {
                    editor.on('init', () => {
                        editor.setContent(el.value || '');
                    });
                    editor.on('Change KeyUp', () => {
                        const model = el.dataset.model;
                        if (model) {
                            Livewire.dispatch('input', { name: model, value: editor.getContent() });
                        }
                    });
                }
            });
        });
    }

    function destroyTinyMCEEditors() {
        document.querySelectorAll('textarea.tinymce').forEach((el) => {
            const editor = tinymce.get(el.id);
            if (editor) {
                editor.destroy();
            }
        });
    }

    function syncAllTinyMCEDataToLivewire() {
        document.querySelectorAll('textarea.tinymce').forEach((el) => {
            const editor = tinymce.get(el.id);
            if (editor) {
                const model = el.dataset.model;
                if (model) {
                    Livewire.dispatch('input', { name: model, value: editor.getContent() });
                }
            }
        });
    }

    document.addEventListener('DOMContentLoaded', function () {
        const el = document.getElementById('components-sortable');

        if (el) {
            new Sortable(el, {
                animation: 150,
                handle: '.drag-handle',
                onStart: destroyTinyMCEEditors,
                onEnd: () => {
                    setTimeout(() => {
                        Livewire.dispatch('reorderUpdated');
                        initTinyMCEEditors();
                    }, 100);
                }
            });
        }

        initTinyMCEEditors();
        Livewire.hook('message.processed', initTinyMCEEditors);
    });
</script>

Step 3: Sortable Container Blade Example

<ul id="components-sortable">
    @foreach ($components as $index => $component)
        <li class="component-item">
            <div class="drag-handle cursor-move">⠿</div>

            @include('form-fields', [
                'fields' => $component['fields'],
                'modelPath' => "components.{$index}.fields"
            ])
        </li>
    @endforeach
</ul>

Step 4: Sync TinyMCE Data Before Save

Since TinyMCE keeps data internally, Livewire won’t automatically have the latest content.

Fix this by syncing before save:

<button type="button" onclick="syncAllTinyMCEDataToLivewire(); Livewire.find('{{ $this->id }}').save()">Save</button>

And in your Livewire component:

protected $listeners = ['save-form' => 'save'];

public function save()
{
    // At this point, TinyMCE data is synced
}

Conclusion

Integrating TinyMCE with Livewire and Sortable.js in a dynamic form builder may seem complex at first, but once set up correctly, it opens up powerful possibilities for building highly interactive and user-friendly applications. By combining the real-time reactivity of Livewire, the drag-and-drop flexibility of Sortable.js, and the rich editing features of TinyMCE, you can create a seamless experience where users can easily manage, reorder, and edit dynamic content without page reloads.

This approach not only improves usability but also ensures that your forms remain scalable and maintainable as your project grows. With the right configuration and careful handling of event updates, you’ll have a robust solution ready for production.

Mastering CSS Media Queries: Optimizing for Retina & Touch Devices

Learn how to use advanced CSS media queries to optimize sliders for Retina iPhones, high-DPI devices, and hybrid touchscreens.

When building responsive websites, CSS media queries are your best friend. They let you fine-tune layouts for different devices, screen sizes, resolutions, and input types. But sometimes media queries can look complicated — especially when dealing with device widths, pixel ratios, and hover/pointer detection.

In this post, we’ll break down three CSS media queries that are often used to optimize sliders (.ftslider) for iPhones, high-density screens, and hybrid touch devices. By the end, you’ll understand exactly what each query targets, why it’s written that way, and how it affects your design.

Targeting iPhone X–Style Devices

@media only screen and (min-device-width: 375px) and (max-device-width: 812px) 
and (-webkit-min-device-pixel-ratio: 3) {
  .ftslider { height: calc(90vh - 85px); }
}

The above @media query targets iPhone X–style devices (and similar), because:

  • min-device-width: 375px – the device width is at least 375px.
  • max-device-width: 812px – the device width is at most 812px.
    • This matches iPhones in the 375×812 portrait range (like iPhone X, XS, 11 Pro, etc.).
  • -webkit-min-device-pixel-ratio: 3 → ensures the device has a Retina 3x screen density.

Handling High-Resolution Displays (Cross-Browser)

@media only screen and (-webkit-min-device-pixel-ratio: 3),
only screen and (-o-min-device-pixel-ratio: 3/1),
only screen and (min-resolution: 458dpi),
only screen and (min-resolution: 3dppx) {
  .ftslider { height: calc(90vh - 85px); }
}

This @media query targets high-resolution displays (3x pixel density or higher), but in a cross-browser compatible way:

  • -webkit-min-device-pixel-ratio: 3 – WebKit (Safari, Chrome).
  • -o-min-device-pixel-ratio: 3/1 – Opera (old syntax).
  • min-resolution: 458dpi – very high DPI screens (approx 3× standard 96dpi).
  • min-resolution: 3dppx – modern way to express “3 device pixels per CSS pixel”.

Hybrid Devices: Hover + Coarse Pointer

@media only screen and (hover: hover) and (pointer: coarse) {
  .ftslider { height: calc(85vh - 55px); }
}

It targets devices that support hover and use a coarse pointer (e.g., touch + stylus hybrids).

Why This Matters

  • Better UX: Users on high-DPI devices get clean, non-clipped layouts.
  • Cross-platform support: Android, iOS, tablets, and hybrid devices all behave predictably.
  • Future-proofing: By writing broad yet precise queries, your design adapts to new device types.

Conclusion

Media queries may look intimidating at first glance, but each one has a specific purpose. In this case, they ensure sliders adapt smoothly across iPhones, high-DPI displays, and hybrid touch devices.

When building responsive designs, don’t just think in terms of width and height – consider pixel density, hover ability, and pointer type. That’s the key to delivering a seamless user experience on any screen.