> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sevalla.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the docs for Sevalla, a platform for deploying applications, databases, static sites, object storage and load balancers. The dashboard is at https://app.sevalla.com.
> Bold text in steps is an exact dashboard label.
> To act on a Sevalla account, prefer the Sevalla MCP server (https://mcp.sevalla.com/mcp, see https://docs.sevalla.com/ai-agents/overview.md), then the Sevalla CLI (https://docs.sevalla.com/developer-tools/cli.md), then the REST API (https://api-docs.sevalla.com, OpenAPI spec at https://api.sevalla.com/v3/openapi.json).
> Every page index is at https://docs.sevalla.com/llms.txt.

# Store Laravel files in object storage

> Store Laravel uploads in a Sevalla object storage bucket with the s3 filesystem disk, and link to them with temporary URLs or, from a public bucket, permanent URLs.

Laravel saves files to its `local` disk, in `storage/app`, by default, and Sevalla resets an application's filesystem on every deployment. With Laravel's `s3` disk, Laravel saves them in a Sevalla [object storage](https://docs.sevalla.com/object-storage/overview.md) bucket instead, through its S3-compatible API, so every instance and every deployment sees the same files.

## At a glance

What this guide adds to the application from [Deploy a Laravel application](https://docs.sevalla.com/guides/php/laravel/deploy.md#at-a-glance):

| | |
| - | - |
| Services | A Sevalla object storage bucket, private by default, reached over its S3-compatible API |

### Environment variables

| Variable | Required | Available during | Secret | Value |
| - | - | - | - | - |
| `FILESYSTEM_DISK` | Yes | Runtime | No | `s3` |
| `AWS_BUCKET` | Yes | Runtime | No | The bucket's **Bucket name** (`bucket_name` in the API), for example `storefront-media-q8w2e` |
| `AWS_ENDPOINT` | Yes | Runtime | No | The bucket's **Endpoint** (`endpoint`), `https://<account-id>.r2.cloudflarestorage.com` |
| `AWS_ACCESS_KEY_ID` | Yes | Runtime | Yes | The bucket's **Access key** (`access_key`) |
| `AWS_SECRET_ACCESS_KEY` | Yes | Runtime | Yes | The bucket's **Secret key** (`secret_key`) |
| `AWS_DEFAULT_REGION` | Yes | Runtime | No | `auto` |
| `AWS_URL` | Public buckets only | Runtime | No | `https://` and the bucket's **Public domain** (`domain`) |

The application's other variables are in [Deploy a Laravel application](https://docs.sevalla.com/guides/php/laravel/deploy.md#environment-variables).

## Before you begin

* A Laravel application deployed on Sevalla, as in [Deploy Laravel](https://docs.sevalla.com/guides/php/laravel/deploy.md).

## Create a bucket

**1. Create the bucket**

In the sidebar, click **Object storage**, then click **Create object storage**. Enter `storefront-media` as the **Name**, choose the **Location** closest to the application, and click **Create object storage**.

**2. Decide who can read the files**

Keep the bucket private to serve each file through a link that expires. To serve files to anyone with a permanent URL, click the bucket's **Settings** tab, click **Enable public access** under **Public access**, then click **Enable public access** in the dialog.

## Install the S3 driver

Laravel's `s3` disk needs Flysystem's S3 adapter, which installs the AWS SDK for PHP:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies
```

## Add the bucket details to the application

Laravel's `config/filesystems.php` reads the `s3` disk's settings from these variables, so the configuration files don't change. The values come from the bucket's **Settings** tab; see [Connect with the S3 API](https://docs.sevalla.com/object-storage/connect.md) for each one.

| Variable | Value |
| - | - |
| `FILESYSTEM_DISK` | `s3`, which makes the bucket Laravel's default disk |
| `AWS_BUCKET` | **Bucket name** under **Details**, for example `storefront-media-q8w2e`. It's not the display name. |
| `AWS_ENDPOINT` | **Endpoint** under **Details**, in the form `https://<account-id>.r2.cloudflarestorage.com` |
| `AWS_ACCESS_KEY_ID` | **Access key** under **Access keys** |
| `AWS_SECRET_ACCESS_KEY` | **Secret key** under **Access keys** |
| `AWS_DEFAULT_REGION` | `auto` |
| `AWS_URL` | Public buckets only: `https://` and the **Public domain** under **Public access**, for example `https://storefront-media-q8w2e.sevalla.storage` |

Leave `AWS_USE_PATH_STYLE_ENDPOINT` unset: the bucket accepts requests with the bucket name in the host name, as Laravel sends them by default.

![A bucket's Settings tab with the display name, location, bucket name, ID and endpoint, and the access key and secret key](https://assets.sevalla.com/screenshots/light/object-storage-settings-details.png)

**1. Open the environment variables**

In the sidebar, click **Applications**, then click the application. Click the **Environment variables** tab.

**2. Add the variables**

Click **Add environment variables**. Add the variables and click **Add environment variables**. They apply with the next deployment.

## Store uploads

Add a route that saves an uploaded photo in the bucket and returns a link to it:

```php routes/web.php theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;

Route::get('/', function () {
    return view('welcome');
});

Route::post('/photos', function (Request $request) {
    $request->validate(['photo' => ['required', 'image', 'max:10240']]);

    $path = $request->file('photo')->store('photos');

    return [
        'path' => $path,
        'url' => Storage::temporaryUrl($path, now()->addHour()),
    ];
});
```

* `store('photos')` saves the file on the default disk, the bucket, under `photos/` with a random name, and returns its path, for example `photos/lMI1BYDbMqPcsYKXrHX2n5qT5ZGQBOXfrOd74drU.png`. Save the path with your model.
* `max:10240` limits uploads to 10 MB. Ingot's PHP accepts uploads up to 64 MB.
* Send the file from a form with `enctype="multipart/form-data"`, a file field named `photo` and `@csrf`, as every `POST` route in `routes/web.php` needs a CSRF token.

Commit and push the changes, `composer.json` and `composer.lock` included: Ingot installs the S3 adapter on the next build. If **Auto-deploy** is off, click **Deploy now** on the application's **Overview** tab.

## Link to the files

For a private bucket, `Storage::temporaryUrl($path, now()->addHour())` returns a [presigned URL](https://docs.sevalla.com/object-storage/connect.md#share-files-with-presigned-urls) that works for 1 hour, up to 7 days. Create it when you show the page, not when you save the file.

For a public bucket with `AWS_URL` set, `Storage::url($path)` returns a permanent URL on the bucket's public domain, for example `https://storefront-media-q8w2e.sevalla.storage/photos/lMI1BYDbMqPcsYKXrHX2n5qT5ZGQBOXfrOd74drU.png`. Public access applies to the whole bucket: the bucket doesn't keep per-file visibility, so `Storage::getVisibility()` fails with `GetObjectAcl not implemented`. Keep private files in a second, private bucket, with a [second disk](https://laravel.com/docs/filesystem#configuration) in `config/filesystems.php`. See [Public URLs](https://docs.sevalla.com/object-storage/connect.md#public-urls).

## Update the Dockerfile

If the application builds from the [Dockerfile](https://docs.sevalla.com/guides/php/laravel/deploy.md#deploy-with-a-dockerfile) in the deploy guide, raise PHP's upload limits, which are 2 MB per file in PHP's production `php.ini`, to Ingot's 64 MB. Add a line to the `php.ini` settings of the first `RUN` instruction, before the `> "$PHP_INI_DIR/conf.d/production.ini"` line:

```dockerfile Dockerfile theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
        'upload_max_filesize = 64M' 'post_max_size = 64M' \
```

The AWS SDK needs no other extension or package: it uses PHP's built-in `curl` and the root certificates the image already installs.

## Verify

When the deployment's status is **Deployed**, click the application's **Web terminal** tab, and in the shell that opens in the **Web** process, save a test file through Laravel's storage:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
php artisan tinker --execute "Storage::put('hello.txt', 'Hello from Sevalla'); echo Storage::temporaryUrl('hello.txt', now()->addHour());"
```

The command prints the file's temporary URL. Open it: the page reads `Hello from Sevalla`. For a public bucket, `Storage::url('hello.txt')` prints `https://storefront-media-q8w2e.sevalla.storage/hello.txt`, which shows the same text. In the sidebar, click **Object storage**, click the bucket, and the **Objects** tab lists `hello.txt`. To remove it, run `Storage::delete('hello.txt')` the same way, or delete it on the **Objects** tab. See [Manage objects](https://docs.sevalla.com/object-storage/objects.md).

If `Storage::put()` returns `false`, Laravel couldn't write to the bucket: its `s3` disk doesn't throw errors by default. Check the variables, and set `'throw' => true` for the `s3` disk in `config/filesystems.php` to see the error.

### Check from a terminal

These checks need no dashboard. `API`, `H`, `APP_ID`, `WEB_ID` and `BUCKET_ID` are set as in [Deploy with the API](#deploy-with-the-api).

1. Save a file and get its temporary URL, with [Run a command in a process](https://api-docs.sevalla.com/v3/applications/run-a-command-in-a-process) (`execProcessCommandV3`) in the web process, the API's counterpart of the web terminal. The command runs without a shell, and the key needs write access to the application. Then open the URL:

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   URL=$(curl -s -X POST "$API/applications/$APP_ID/processes/$WEB_ID/exec" -H "$H" -H "Content-Type: application/json" \
     -d '{"command": ["php", "artisan", "tinker", "--execute", "Storage::put(\"hello.txt\", \"Hello from Sevalla\"); echo Storage::temporaryUrl(\"hello.txt\", now()->addHour());"], "timeout": 30}' \
     | jq -r .stdout)
   curl -s "$URL"
   ```

   ```text theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   Hello from Sevalla
   ```

2. The file in the bucket, from [List objects](https://api-docs.sevalla.com/v3/object-storages/list-objects) (`listObjectStorageObjectsV3`):

   ```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   curl -s "$API/object-storage/$BUCKET_ID/objects" -H "$H" | jq -c '.data[] | {key, size}'
   ```

   ```json theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
   {"key":"hello.txt","size":18}
   ```

To remove it, run `Storage::delete('hello.txt')` the same way, or call [Delete objects](https://api-docs.sevalla.com/v3/object-storages/delete-objects). CLI: `sevalla object-storage objects list --bucket-id $BUCKET_ID`. MCP: `sevalla_api_update` with `execProcessCommandV3`, and `sevalla_api_read` with `listObjectStorageObjectsV3`.

## Deploy with the API

The same steps as [Create a bucket](#create-a-bucket) and [Add the bucket details to the application](#add-the-bucket-details-to-the-application), as calls to the [Sevalla API](https://docs.sevalla.com/developer-tools/api.md). `APP_ID` and `WEB_ID` are the application and its web process from [Deploy with the API](https://docs.sevalla.com/guides/php/laravel/deploy.md#deploy-with-the-api) in the deploy guide, with `API` and `H` set as there. The API key also needs the capabilities to create and read object storage. Push the S3 driver and the route first: the deployment builds the branch as it is.

**1. Create the bucket**

[Create object storage bucket](https://api-docs.sevalla.com/v3/object-storages/create-object-storage-bucket) (`createObjectStorageV3`) returns the bucket's name, endpoint and keys. `location` is the region hint closest to the application, here `weur` (Western Europe) for Frankfurt; send `"public_access": true` for a public bucket:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
BUCKET=$(curl -s -X POST "$API/object-storage" -H "$H" -H "Content-Type: application/json" \
  -d '{"display_name": "storefront-media", "location": "weur"}')
BUCKET_ID=$(echo "$BUCKET" | jq -r .id)
echo "$BUCKET" | jq -c '{bucket_name, endpoint, domain}'
```

The response holds the secret key, so the command prints only the other fields. CLI: `sevalla object-storage create --name storefront-media --location weur`. MCP: `sevalla_api_create` with `createObjectStorageV3` and the same body.

**2. Add the environment variables**

[Create environment variable](https://api-docs.sevalla.com/v3/applications/create-environment-variable) (`createEnvironmentVariableV3`), once per variable, from the bucket's details, for runtime only:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for pair in "FILESYSTEM_DISK=s3" "AWS_DEFAULT_REGION=auto" \
  "AWS_BUCKET=$(echo "$BUCKET" | jq -r .bucket_name)" "AWS_ENDPOINT=$(echo "$BUCKET" | jq -r .endpoint)" \
  "AWS_ACCESS_KEY_ID=$(echo "$BUCKET" | jq -r .access_key)" "AWS_SECRET_ACCESS_KEY=$(echo "$BUCKET" | jq -r .secret_key)"; do
  curl -s -X POST "$API/applications/$APP_ID/env-vars" -H "$H" -H "Content-Type: application/json" \
    -d "$(jq -n --arg key "${pair%%=*}" --arg value "${pair#*=}" '{key: $key, value: $value, is_runtime: true, is_buildtime: false}')" \
    | jq -r .key
done
```

For a public bucket, add `AWS_URL` with `https://` and the bucket's `domain` too. CLI: `sevalla apps env-vars create $APP_ID --key FILESYSTEM_DISK --value s3`, once per variable, for build and runtime. MCP: `set_environment_variables` with `runtime: true` and `buildtime: false`.

**3. Deploy**

[Trigger deployment](https://api-docs.sevalla.com/v3/applications/trigger-deployment) (`triggerDeploymentV3`):

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
DEPLOYMENT_ID=$(curl -s -X POST "$API/applications/$APP_ID/deployments" -H "$H" -H "Content-Type: application/json" \
  -d '{}' | jq -r .id)
```

CLI: `sevalla apps deployments trigger $APP_ID`. MCP: `trigger_deployment`.

**4. Wait for the deployment**

Poll [Get deployment](https://api-docs.sevalla.com/v3/applications/get-deployment) (`getDeploymentV3`) until the status is final: `success`, `failed`, `cancelled` or `skipped`. The loop keeps polling through a failed request and stops after 30 minutes:

```bash theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
for i in $(seq 1 120); do
  STATUS=$(curl -s "$API/applications/$APP_ID/deployments/$DEPLOYMENT_ID" -H "$H" | jq -r '.status // empty')
  case $STATUS in success|failed|cancelled|skipped) break ;; esac
  sleep 15
done
echo "$STATUS"
```

CLI: `sevalla apps deployments get $DEPLOYMENT_ID --app-id $APP_ID`. MCP: `get_deployment`.

Then run the checks in [Check from a terminal](#check-from-a-terminal).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.