DEV Community

Cover image for Building a SaaS engine in public: the file layer, and the public_path() habit I had to unlearn
Dmitry Isaenko
Dmitry Isaenko

Posted on

Building a SaaS engine in public: the file layer, and the public_path() habit I had to unlearn

This is part of a series where I pull a working SaaS core out of a CRM I built and run, and ship it in public as a versioned Composer package, one module at a time. Previous phases covered auth, multi-tenancy, RBAC, the activity log, multilanguage, and navigation. This one is the file layer: how the core stores, serves and defaults images and files. It is the last piece of plumbing before billing.

There is no dramatic security hole this time. The honest story is smaller and more familiar: a habit from the donor CRM that worked fine for one app on one server, and falls apart the moment the code is supposed to be reusable and deployable anywhere. The habit was writing files with public_path().

What this phase is

Four things, all small, all sharing one contract:

  1. A file engine that stores uploads through a configured disk, with a generated filename and optional image variants.
  2. Avatars and logos routed through that engine, including a default avatar that costs nothing to store.
  3. Private files: a disk with no public URL, reached only through a short-lived signed door.
  4. Vue components for upload, preview and display, so the host does not rewrite a file input five times.

And, on purpose, NOT a full media library. More on that at the end.

The habit: public_path()

Here is roughly what the donor did to save a user's avatar:

// donor CRM, paraphrased
$folder = 'user-logos';
$path = public_path($folder.'/'.$subfolder.'/'.$filename);

if (! is_dir(dirname($path))) {
    mkdir(dirname($path), 0755, true);
}

self::resizeAndSaveImage($file, $path);
Enter fullscreen mode Exit fullscreen mode

It works. You upload an avatar, it lands in public/user-logos/..., the browser can fetch it by URL, done. The company logo service did the same thing with storage_path('app/public/...') and its own mkdir.

The problem is not that it is wrong for that app. The problem is everything it assumes:

  • It assumes the files live next to the code, in the deployable web root. Redeploy by replacing the directory (which is how a lot of CI deploys work) and the uploaded files are gone.
  • It assumes a local filesystem. There is no mkdir on S3. The instant you want object storage, every one of these call sites has to be rewritten.
  • It assumes the app owns the path. Hardcoded folder, hardcoded disk, hardcoded structure. A host app that wants its avatars somewhere else has no say.

For a single CRM I run myself, none of that bit me. For a core other people will deploy to hosting I will never see, all three are real. So the rebuild does the boring Laravel-correct thing: everything goes through Storage::disk($config).

The seam: one contract, a configured disk

There is one interface the rest of the core depends on, MediaStorage, and one implementation behind it that talks to Storage::disk():

interface MediaStorage
{
    public function store(UploadedFile|string $file, string $directory, array $options = []): StoredFile;
    public function url(string $path, ?string $disk = null): string;
    public function temporaryUrl(string $path, ?int $minutes = null, ?string $disk = null): string;
    public function delete(string $path, ?string $disk = null): bool;
}
Enter fullscreen mode Exit fullscreen mode

The disk comes from config. The filename is a generated UUID, never the client's filename, so an upload cannot smuggle a path like ../../something into the directory structure. Files land under a date-sharded path (avatars/2026/06/<uuid>.jpg) so a single directory never grows unbounded.

And the payoff is the part I actually care about. The config default is the public disk. Change it to s3 and avatars and logos move to the bucket. No call site changes, because no call site ever named a disk or a path on disk. The donor's public_path() could not do that without a rewrite. This one is a one-line config edit, and a host integration test asserts the file lands on whatever disk the host configured, not a hardcoded one.

public_path() and mkdir() appear nowhere in the new code. That is the whole point of the phase.

The avatar column that meant two different things

While wiring this up, the extract surfaced a smaller real issue. The User.avatar column held one of two completely different kinds of value depending on how the user signed up:

  • A relative path, avatars/2026/06/abc.jpg, for a user who uploaded one.
  • An absolute URL, https://lh3.googleusercontent.com/..., for a user who signed in with an OAuth provider that handed back an avatar URL.

If you write the accessor the obvious way, Storage::disk()->url($this->avatar), you are fine for the first case and broken for the second: you prefix the disk's base URL onto an already-absolute URL and produce a dead link.

So the resolver tells the two apart, plus a third case for users with nothing stored:

public static function avatarUrl(?string $stored, string $seed): string
{
    $stored = is_string($stored) ? trim($stored) : '';

    // nothing stored -> draw an initials placeholder from the name
    if ($stored === '') {
        return app(AvatarGenerator::class)->url($seed);
    }

    // an OAuth provider's URL -> return it untouched, do NOT re-prefix it
    if (self::isAbsoluteUrl($stored)) {
        return $stored;
    }

    // a path we stored -> resolve through the configured disk
    return app(MediaStorage::class)->url($stored);
}
Enter fullscreen mode Exit fullscreen mode

Two different questions in one column, separated in one place rather than copy-pasted onto every model that has an avatar. The Company logo accessor uses the same helper. Small, but it is exactly the kind of thing that looks fine in a single app and bites once the same column is filled by two different code paths.

A missing avatar should cost zero files

The donor's approach to a default avatar was to generate an image and save it. Gravatar probe, fall back to drawn initials, resize, write the file. Now every user has a stored avatar file whether they uploaded anything or not, and every one of those is a file you have to track, and orphan, and eventually clean up.

The rebuilt default avatar writes nothing. It draws the initials inline as an SVG data URI:

$svg = $avatar->create($initialsFrom($seed))->toSvg();

return 'data:image/svg+xml;base64,'.base64_encode($svg);
Enter fullscreen mode Exit fullscreen mode

A user with no uploaded image produces zero stored files. There is nothing to orphan and nothing to prune. The colour is derived deterministically from the name, so the same person always gets the same placeholder, and it needs no image extension at all, because building an SVG string is just string building. (More on the extension in a second.)

If a host wants Gravatar, or wants to render a real PNG, they rebind one contract (AvatarGenerator) in a service provider and every accessor in the core switches at once. The core ships initials, because initials are the option that cannot fail at sign-up time with an outbound HTTP call.

Private files: a door, not a folder

Avatars and logos are public by nature, served by URL. But the seam I will need later (order documents, invoices in a future phase) is the opposite: files that must not be reachable by guessing a URL.

The mechanism is a private disk with no public URL, plus one route that is the only door to it:

Route::middleware(['web', 'auth', 'signed'])
    ->get('larafoundry/media/private', PrivateFileController::class)
    ->name('larafoundry.media.private');
Enter fullscreen mode Exit fullscreen mode

temporaryUrl() mints a short-lived signed URL to that route, carrying the path and the disk in the signed query string. Three things have to be true for a download to succeed: the signature is valid and unexpired (signed), the user is logged in (auth), and the disk in the signed query matches the configured private disk. That last check is the one I want to call out:

$disk = (string) $request->query('disk', config('larafoundry-media.private_disk'));

// even with a valid signature, refuse any disk but the configured private one,
// so a signed URL can never be re-pointed at the public disk or an arbitrary one
abort_unless($disk === (string) config('larafoundry-media.private_disk'), 403);
Enter fullscreen mode Exit fullscreen mode

The signature guarantees the value was not tampered with after the app minted it, and this check guarantees that even a future caller who signs a different disk cannot steer the route at the public disk or somewhere arbitrary. Combined with the UUID filenames, a crafted path cannot walk out of the disk root either.

The host layers its own per-record authorization on top by only minting the URL after its own check ("only this order's owner may download its invoice"). The core's job is narrower and worth stating exactly: it guarantees the URL is unforgeable and expiring, not who is allowed to mint it. On S3 you can flip a config value to presigned and the file streams from the bucket directly instead of through the app.

The packaging detail: GD is a suggest, not a require

One choice that took a minute to get right. Image resizing uses intervention/image, which needs the GD or Imagick PHP extension. The tempting thing is to put ext-gd in the package's require.

But the default avatar, the thing every user without an upload sees, needs no extension at all, because it is an SVG string. If ext-gd were a hard require, the package would refuse to install on a shared host that does not have it, even for an app that never resizes a single image.

So ext-gd is a suggest, not a require. The package installs anywhere. GD is only touched when a real image is actually uploaded and processed, and a host that never does that never needs it. The config picks the driver (gd or imagick) and the manager resolves it lazily, so the extension requirement only materializes at the moment an image is decoded.

What v0.8.0 is not

Same honesty section as every release.

This is NOT a full media library. There is no polymorphic media table, no HasMedia trait letting any model attach N files with conversions. What ships is the storage contract and a StoredFile DTO, designed so that table and trait can be added later without rewriting the call sites that already exist. It is the same deferred-seam move I used for the hasAccess() stub before billing: build the shape now, fill it when there is a real consumer (ticket attachments, product images, order documents in later phases).

The private-file door is real and tested, but nothing in the core uses it yet. Avatars and logos are public. The private mechanism exists as the seam for documents that arrive with later domain modules, not as a feature you can point at today.

Not in scope, and not claimed: virus scanning, CDN integration, an in-browser image cropper, and any concrete domain attachment. Those are host concerns or later phases, noted as known limitations rather than quietly implied.

The thread, again

Every phase in this series has the same shape. The interesting code is fine. The lesson is in a default, or a seam, or a habit that was correct for one app and wrong for a reusable one. This time it was public_path(): a perfectly working way to save a file that silently assumes local disk, a fixed structure, and files living in the deployable web root. None of which a core shipped to strangers gets to assume.

The file engine was the work. Making it disk-agnostic was the point.

Code is on GitHub, the package is versioned, every module ships with Pest tests and a security pass before it merges. That closes phase 2. Next up is billing.

LaraFoundry

A reusable SaaS/CRM core for Laravel, extracted in public from a production system.

LaraFoundry is a modular SaaS foundation being extracted from Kohana.io, a real production CRM/ERP. The goal is to package the cross-cutting parts every SaaS rebuilds from scratch (auth, multi-tenancy, i18n, admin, billing) as a clean, tested Composer package, so you don't write them again.

This is built in public and by extraction, not rewrite. Each piece is pulled from battle-tested production code, modernized, hardened, covered with Pest, reviewed, and only then tagged. The README tracks what is actually in the package, not what is planned. See the roadmap for what's coming.

Tech stack: Laravel 12 / 13, PHP 8.2+, Inertia 2 / 3, Vue 3, Tailwind CSS 4, Ziggy, Pest. Authentication builds on Laravel Fortify and Socialite; the activity log builds on spatie/laravel-activitylog; the media library builds on intervention/image and laravolt/avatar




Top comments (0)