A Laravel customer portal is a logged-in area where clients can access their own projects, files, invoices, and support tickets without seeing anyone else's data. To build one, you need four core components: company accounts with invited team members, strict data isolation to prevent clients from accessing each other's records, private file sharing, and billing and support views. Laravel provides the building blocks for all of these, from policies and signed URLs to Cashier.

Every agency eventually hits the same wall. Client updates live in email threads, files sit in five shared folders, and "Can you resend that invoice?" lands in your inbox every week. A client portal fixes that. It's also one of the most requested agency deliverables because every service business wants one.

This guide shows you how to build a customer portal in Laravel, covering the data model, team invites, data isolation, secure file downloads, invoices, tickets, and a pre-launch security checklist.

Key Takeaways

  • Model clients as companies, not users. A companies table with multiple users lets clients invite teammates and keeps records tied to the account rather than one person.

  • Isolate data twice. A global scope filters every query by company_id, while a policy checks every individual record. Relying on either layer alone can eventually lead to data leaks.

  • Use signed, expiring URLs for invites with URL::temporarySignedRoute() and the signed middleware so invite links can't be guessed or reused indefinitely.

  • Never serve client files from public/. Store them on a private disk and stream them through a controller that authorizes access first.

  • Most of a portal is standard CRUD, so generating the base app with AI and focusing your time on client-specific features is the fastest approach.

The agencies and founders featured in this article's examples are illustrative composites based on common Laravel projects.

What Goes in a Laravel Customer Portal?

Every client portal is different, but most share the same core features. Here's what clients expect and which Laravel tools support each feature:

Feature

What clients do

Laravel building block

Accounts and teams

Log in and invite colleagues

Auth starter kit, signed invite URLs

Projects or orders

Track status and milestones

Eloquent models, policies

Files

Download deliverables and upload assets

Private storage disk, streamed downloads

Invoices

View and download invoices, pay online

Laravel Cashier (Stripe)

Support tickets

Ask questions and see replies

Tickets and replies models, notifications

Notifications

Receive emails when something changes

Laravel Notifications, queues

Staff admin

Your team manages everything

Filament or Nova admin panel

Start with accounts, isolation, and one feature your clients care about most. Add the rest once the foundation is secure.

Build vs. Buy

Hosted client portal tools work well for basic file-and-invoice portals. Building your own in Laravel makes sense when the portal needs to display data from your own systems, such as order status, usage, project milestones, or custom reports. It also makes sense when you want consistent branding throughout or you're an agency selling the portal as a product to clients.

The Customer Portal Data Model

The key decision is this: records belong to a company, and users belong to a company. Don't associate projects with individual users. When a client's employee leaves, the projects must remain with the account.

// database/migrations/xxxx_create_companies_table.php
Schema::create('companies', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('stripe_id')->nullable()->index(); // Cashier
    $table->timestamps();
});

// Add company + role to users
Schema::table('users', function (Blueprint $table) {
    $table->foreignId('company_id')->nullable()->constrained()->cascadeOnDelete();
    $table->string('role')->default('member'); // owner, member, staff
});

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('company_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->string('status')->default('active');
    $table->timestamps();
});

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->foreignId('company_id')->constrained()->cascadeOnDelete();
    $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
    $table->foreignId('uploaded_by')->constrained('users');
    $table->string('path');
    $table->string('original_name');
    $table->timestamps();
});

Tickets, invoices, and other client-facing records follow the same rule: every table needs a company_id column.

Your own staff members have company_id = null and role = staff. That distinction drives the entire isolation layer.

Data Isolation: The Part You Can't Get Wrong

A customer portal's primary job is to show clients their own data. One missing where clause, and Client A can download Client B's contract. That's not just a bug. It's a data breach.

Use two layers.

Layer 1: A Global Scope on Every Client-Owned Model

// app/Models/Concerns/BelongsToCompany.php
trait BelongsToCompany
{
    protected static function bootBelongsToCompany(): void
    {
        static::addGlobalScope('company', function (Builder $query) {
            $user = auth()->user();

            // Staff see everything; clients only see their company
            if ($user && ! $user->isStaff()) {
                $query->where(
                    $query->getModel()->getTable() . '.company_id',
                    $user->company_id
                );
            }
        });

        static::creating(function (Model $model) {
            $user = auth()->user();

            if ($user && ! $user->isStaff()) {
                $model->company_id = $user->company_id;
            }
        });
    }

    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }
}

Add use BelongsToCompany; to Project, Document, Ticket, and every other client-owned model. Now, Project::all() returns only the current client's projects, and records created by clients are automatically assigned to their company.

Two caveats matter:

  • Keep company_id and role out of $fillable on every model, including User. The creating hook sets company_id directly, so it still works, but a tampered update form can't move a record to another company or promote a user.

  • The scope only applies when a client is logged in. Queued jobs, scheduled commands, and Tinker have no authenticated user, so they run without that user-based filtering. In jobs, always query with an explicit where('company_id', $this->companyId) instead of relying on the scope.

If you're building a full multi-tenant SaaS rather than a portal, our Laravel multi-tenancy guide compares this single-database approach with database-per-tenant.

Layer 2: A Policy on Every Record

Global scopes are easy to bypass accidentally, for example, by using withoutGlobalScopes() in a report or running a raw query. Every controller action should also check a policy:

// app/Policies/DocumentPolicy.php
class DocumentPolicy
{
    public function view(User $user, Document $document): bool
    {
        return $user->isStaff() || $user->company_id === $document->company_id;
    }

    public function delete(User $user, Document $document): bool
    {
        return $user->isStaff()
            || ($user->company_id === $document->company_id && $user->role === 'owner');
    }
}

Laravel discovers policies automatically when they follow the Model + Policy naming convention (Laravel authorization docs). Call them from controllers using Gate::authorize(), which works with the slim base controller in Laravel 11 and later:

public function show(Document $document)
{
    Gate::authorize('view', $document);

    return view('portal.documents.show', compact('document'));
}

Our guide to Laravel policies and gates covers roles, before() hooks for staff, and policy testing in depth.

Tomás, a freelance developer in Porto, learned this the hard way in 2025. His first client portal relied only on a global scope. A month after launch, he added an "all documents" export for his staff using withoutGlobalScopes(). He forgot to restrict the route, and a client discovered it through a link in an email. Nothing sensitive leaked, but he spent a weekend auditing logs. Every portal he's built since checks a policy on every route, even when the scope should already provide protection.

Want the isolation layer generated for you? StackMivo builds Laravel apps with models, policies, and an admin panel from a plain-English description, so you can start with a foundation instead of a blank project.

Team Invites with Signed URLs

Clients want to add their own colleagues. The safest pattern is an invites table with company_id, email, and accepted_at, combined with a signed, expiring link. First, define a policy rule that controls who can invite others:

// app/Policies/CompanyPolicy.php
class CompanyPolicy
{
    public function invite(User $user, Company $company): bool
    {
        return $user->company_id === $company->id && $user->role === 'owner';
    }
}
// Sending an invite (owner only)
public function store(Request $request)
{
    Gate::authorize('invite', $request->user()->company);

    $data = $request->validate([
        'email' => ['required', 'email', 'unique:users,email'],
    ]);

    $invite = $request->user()->company->invites()->create($data);

    $url = URL::temporarySignedRoute(
        'invites.accept',
        now()->addDays(3),
        ['invite' => $invite->id]
    );

    Mail::to($invite->email)->queue(new PortalInvite($invite, $url));

    return back()->with('status', 'Invite sent.');
}
// routes/web.php
Route::middleware('signed')->group(function () {
    // GET only shows a "set your password" form
    Route::get('/invites/{invite}/accept', [AcceptInviteController::class, 'show'])
        ->name('invites.accept');

    // POST does the work (the form posts back to the same signed URL)
    Route::post('/invites/{invite}/accept', [AcceptInviteController::class, 'store']);
});
// AcceptInviteController::store()
public function store(Request $request, Invite $invite)
{
    $data = $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'password' => ['required', 'confirmed', Password::defaults()],
    ]);

    $user = DB::transaction(function () use ($invite, $data) {
        $invite = Invite::whereKey($invite->id)->lockForUpdate()->firstOrFail();
        abort_if($invite->accepted_at, 410, 'This invite has already been used.');

        $user = new User(['name' => $data['name'], 'email' => $invite->email]);
        $user->password = Hash::make($data['password']);
        $user->company_id = $invite->company_id; // from the invite, never the request
        $user->role = 'member';
        $user->save();

        $invite->update(['accepted_at' => now()]);

        return $user;
    });

    Auth::login($user);

    return redirect()->route('portal.dashboard');
}

The signed middleware rejects links that have been tampered with or have expired (Laravel signed URL docs). Performing the action on POST prevents email link scanners from accepting invites simply by clicking them. The row lock and accepted_at check also ensure that each invite can be used only once.

Secure File Sharing

Files are often the main reason clients log in at all. Never store them in public/. In Laravel 11 and later, the default local disk already points to storage/app/private, which isn't web-accessible. Use that disk or a private S3 bucket:

// Upload: authorize, validate, then store under the project's company
public function store(Request $request, Project $project)
{
    Gate::authorize('view', $project);

    $request->validate([
        'file' => ['required', 'file', 'mimes:pdf,png,jpg,docx,xlsx,zip', 'max:20480'], // 20 MB
    ]);

    $path = $request->file('file')->store("companies/{$project->company_id}", 'local');

    $document = new Document([
        'path' => $path,
        'original_name' => $request->file('file')->getClientOriginalName(),
    ]);
    $document->company_id = $project->company_id; // works for staff uploads too
    $document->project_id = $project->id;
    $document->uploaded_by = $request->user()->id;
    $document->save();

    return back()->with('status', 'File uploaded.');
}
// Download: authorize, then stream
public function download(Document $document)
{
    Gate::authorize('view', $document);

    return Storage::disk('local')->download($document->path, $document->original_name);
}

Setting company_id from the project rather than the user ensures that files uploaded by your staff are stored under the correct client's account. Keep the original filename for display only. Laravel generates the stored path, helping prevent users from overwriting each other's files or traversing directories. For large files on S3, return a short-lived temporaryUrl() after the policy check instead of streaming the file through PHP.

Invoices and Payments with Cashier

If you bill clients through Stripe, Laravel Cashier lets the portal list and download invoices directly. Because invoices belong to a company, make Company the billable model:

// app/Models/Company.php
use Laravel\Cashier\Billable;

class Company extends Model
{
    use Billable;
}

// AppServiceProvider::boot()
Cashier::useCustomerModel(Company::class);

Then list and download invoices in the portal:

public function index(Request $request)
{
    // invoices() hides open/unpaid invoices; include them so clients can pay
    $invoices = $request->user()->company->invoicesIncludingPending();

    return view('portal.invoices.index', compact('invoices'));
}

public function download(Request $request, string $invoiceId)
{
    return $request->user()->company->downloadInvoice($invoiceId, [
        'vendor' => 'Your Agency Ltd',
    ]);
}

Because the invoice is fetched through the user's own company, a client can't download another company's invoice simply by changing the ID. PDF downloads require the Dompdf library (composer require dompdf/dompdf). See the Cashier documentation for the required migrations, and our Cashier subscription billing guide if you sell retainers or subscriptions.

Support Tickets and Notifications

A ticket area keeps client requests out of your inbox. The model is straightforward: tickets with company_id, subject, status, and priority, plus ticket_replies with ticket_id, user_id, body, and is_internal. The is_internal flag lets your staff leave private notes that clients must never see. Filter those notes out in the client-facing query, not just in the view.

When something changes, such as a new reply, file, or invoice, send a queued notification. Skip internal notes so they don't end up in the client's inbox:

if (! $reply->is_internal) {
    Notification::send($ticket->company->users, new TicketReplied($reply));
}

We cover statuses, assignment, and SLAs in more detail in our guide to building a ticketing system in Laravel.

Your staff side doesn't need a custom UI. A Filament admin panel gives your team CRUD functionality for companies, projects, files, and tickets in an afternoon. Keep it on its own path, behind a separate auth guard or a staff-only check.

How AI Speeds Up a Customer Portal Build

A customer portal relies mostly on well-established patterns: authentication, CRUD, policies, file uploads, an admin panel, and notifications. That's exactly the kind of work AI app builders handle well.

With StackMivo, you describe the portal in plain English. A prompt like this is a strong starting point:

"Build a Laravel customer portal for a design agency. Clients belong to companies and can invite teammates with expiring email invites. Company owners can manage members. Clients see only their company's projects, documents, and support tickets. Documents are stored on a private disk and downloaded through an authorized controller. Show Stripe invoices through Laravel Cashier with Company as the billable model. Staff manage everything in a Filament admin panel."

You get the models, migrations, relationships, policies, and admin screens as code you own. Then you can spend your time where it matters:

  • Security review. Check that every client-facing route has a policy, and test cross-company access.

  • Client-specific features. Add approvals, milestone sign-off, usage dashboards, or integrations with your other systems.

  • Branding and UX. The portal is part of your service. Clients notice when it feels polished.

Amara runs a six-person web agency in Lagos. In July, a property management client asked for a portal where landlords could see maintenance requests, documents, and monthly statements. She generated the base portal with AI on day one, spent the next week building the statement reports and the client's accounting integration, and delivered the project in under three weeks. Her previous portal project, built from scratch, took seven weeks.

Laravel Customer Portal Security Checklist

Before giving clients access, check each item:

  1. Every client-owned table has a company_id column, and every model uses the company global scope.

  2. Every client-facing controller action calls Gate::authorize(), even when the scope already filters the data.

  3. company_id and role are never mass-assignable or accepted from request input, and queued jobs filter by an explicit company ID.

  4. Invites use signed, expiring URLs, are accepted on POST rather than GET, and can only be used once.

  5. Files live on a private disk, uploads are validated and authorized, and downloads go through an authorized controller.

  6. Internal ticket notes are filtered out in queries and never trigger client notifications.

  7. Login routes are rate-limited, and two-factor authentication is available for client owners.

  8. You have a feature test that logs in as Company A and attempts to access Company B's project, document, ticket, and invoice. All four attempts should fail.

Ready to build yours? Describe your customer portal in plain English and get a working Laravel codebase with companies, policies, and an admin panel. Start free with StackMivo →

Frequently Asked Questions

What is a Laravel customer portal?

A Laravel customer portal is a secure, logged-in area of a Laravel app where clients access their own projects, files, invoices, and support tickets. Each client sees only their company's data.

How long does it take to build a client portal in Laravel?

A basic client portal with accounts, files, and tickets takes a few weeks to build by hand. Generating the base app with an AI app builder can reduce that to days, leaving more time for client-specific features and security reviews.

How do I stop clients from seeing each other's data?

Add a company_id to every client-owned table, apply a global scope that filters by the logged-in user's company, and check a policy on every record. Then write a feature test that attempts cross-company access.

Should I use multi-tenancy packages for a customer portal?

Usually, you don't need to. A single database with a company_id column and a global scope is enough for many portals. Packages like stancl/tenancy are suited to SaaS products that need separate databases or domains per tenant.

Can clients pay invoices through a Laravel portal?

Yes. With Laravel Cashier, you can list a company's Stripe invoices, offer PDF downloads, and link to Stripe-hosted payment pages from inside the portal.

Conclusion

A Laravel customer portal comes down to one principle: every record belongs to a company, and every request must verify that the user is authorized to access that company's data. Get that foundation right with global scopes and policies, and files, invoices, and tickets become routine features.

Start with accounts, invites, and isolation. Add the feature your client cares about most. Then write the cross-company access test before anyone logs in.

Generate your Laravel customer portal with StackMivo, free to start →