Creating and deploying a Laravel REST API on Upsun in 10 minutes

Creating and deploying a Laravel REST API on Upsun in 10 minutes

November 15, 2024· Guillaume Moigneu
Guillaume Moigneu
·Reading time: 10 minutes
💡
This tutorial is part of an upcoming series exploring caching mechanisms in Next.js 15. Stay tuned for the next installment!

Project Overview

In this tutorial, we’ll build a REST API for managing a directory of coffee shops. This backend will later serve as the foundation for a Next.js 15 frontend application, which we’ll cover in the next article of this series.

Setting Up Your Development Environment

We can start by installing our requirements: php, composer, laravel CLI and the upsun CLI. We are using brew for MacOS here but I’m sure you will be able to translate this to other systems!

brew install php8.3

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer

composer global require laravel/installer

brew install platformsh/tap/upsun-cli

Bootstrapping Laravel

First, we’ll create a new Laravel project using the Laravel CLI. Then we’ll add Laravel Sail, which provides a Docker-based development environment with PHP, PostgreSQL, Redis, and other services pre-configured. This gives us a consistent and isolated development environment that mimics production (without being exactly the same).

laravel new coffee-api
composer require laravel/sail
php artisan sail:install

And yes I made a typo on the video. Spelling coffee wrong. Shame. We will be using the correct spelling everywhere else!

Setting up local development

Update your .env file with your preferred settings and add the following hostnames to your /etc/hosts file for local development:

127.0.0.1 api.coffeeshops.test coffeeshops.test

Let’s start everything!

Let’s start our Docker containers using Laravel Sail. This will spin up our development environment with PHP, PostgreSQL and other required services:

sail up
curl http://api.coffeeshops.test

When you make this request, you’ll see Laravel’s default welcome page returned as HTML. This is expected since we haven’t set up our API routes yet - we’ll configure those in the next steps to return JSON responses instead.

Customizing our editor with rules

Before diving into the code, let’s configure our editor to follow Laravel best practices and coding standards.

Create a new file called .cursorrules in your project root. This file will contain AI prompts that help Cursor understand Laravel conventions and provide better code suggestions.

You can find the recommended Laravel rules at cursorrule.com/posts/laravel-php-cursor-rules. Copy the content from there and paste it into your .cursorrules file.

These rules will ensure consistent code style, proper Laravel patterns, and helpful autocompletions as we build our API.

Creating our application

The Laravel API configuration

Let’s configure our application for API development. The following command will:

  • Install Laravel Sanctum for API authentication
  • Set up API routing under the /api prefix
  • Configure the routes/api.php file for our API endpoints
  • Add other API-related packages and configurations
sail artisan install:api

Adding the model

sail artisan make:model Shop -m
sail artisan make:controller Api\\ShopController
sail artisan make:resource ShopResource
sail artisan migrate

Next, we’ll define the database schema for our Shop model by adding fields to the migration file. We’ll also specify which fields can be mass-assigned by adding them to the model’s $fillable property.

2024_11_15_144702_create_shops_table
Schema::create('shops', function (Blueprint $table) {
  $table->id();
  $table->timestamps();

  $table->string('name');
  $table->string('address')->nullable();
  $table->string('city')->nullable();
  $table->string('state')->nullable();
  $table->string('zip')->nullable();
  $table->string('country')->nullable();
  $table->string('phone')->nullable();
  $table->string('website')->nullable();
  $table->float('rating')->default(0);
  $table->string('image')->nullable();
});
app/Models/Shop.php
class Shop extends Model
{
  protected $fillable = [
    'name',
    'address',
    'city',
    'state',
    'zip',
    'country',
    'phone',
    'website',
    'rating',
    'image',
  ];
}

Now let’s refresh our database and run the migrations again to apply these changes:

sail artisan migrate:refresh

Let’s seed our database for testing

Let’s update our DatabaseSeeder.php to generate some sample shop data using Laravel’s factory system:

app/database/seeders/DatabaseSeeder.php
Shop::factory(10)->create();

First, we need to tell Laravel that our Shop model can use factories by adding the HasFactory trait. Add this line at the top of your Shop model file:

app/models/Shop.php
class Shop extends Model
{
  use HasFactory;
  [...]
}

Next, we need to create a factory class to generate test data for our Shop model. We’ll create a new ShopFactory class by extending Laravel’s base factory class and defining how to generate each field:

app/database/factories/ShopFactory.php
public function definition(): array
{
    return [
        'name' => fake()->company(),
        'address' => fake()->streetAddress(),
        'city' => fake()->city(),
        'state' => fake()->state(),
        'zip' => fake()->postcode(),
        'country' => fake()->country(),
        'phone' => fake()->phoneNumber(),
        'website' => fake()->url(),
        'rating' => fake()->numberBetween(1, 5),
        //'image' => fake()->imageUrl(),
    ];
}

Let’s work on our controller

Now let’s update our ShopController to add the index and show methods that will handle our API endpoints:

app/Http/Controllers/Api/ShopController.php
public function index()
{
    return ShopResource::collection(Shop::all());
}

public function show(Shop $shop)
{
    return new ShopResource($shop);
}

For simplicity, we’re implementing basic read CRUD operations without pagination. However, as your dataset grows, you’ll want to add pagination to improve performance and reduce response payload sizes. Laravel makes this easy with methods like paginate().

Creating the routes

Let’s define our API routes by adding the following code to routes/api.php. These routes will handle GET requests for listing all shops and retrieving individual shop details:

Route::get('shops', [\App\Http\Controllers\Api\ShopController::class, 'index']);
Route::get('shops/{shop}', [\App\Http\Controllers\Api\ShopController::class, 'show']);

For a full CRUD API, we could have used Laravel’s Route::apiResource() helper instead, which would automatically define all RESTful routes (index, show, store, update, destroy) in a single line:

Let’s test

Let’s test our newly created API endpoints by making requests to both the collection endpoint /shops and the individual shop endpoint /shops/{id}. This will verify that our routes, controller methods, and resource transformations are working correctly:

curl api.coffeeshops.test/api/shops | jq
{
  "data": [
    {
      "id": 1,
      "name": "Wisozk, Sanford and Rice",
      "address": "318 Caleigh Causeway Apt. 403",
      "city": "Forestburgh",
      "state": "Iowa",
      "zip": "33765-9124",
      "country": "Brunei Darussalam",
      "phone": "+1-231-859-2227",
      "website": "https://www.barton.com/quia-ut-error-voluptatem-qui-eos-similique-expedita",
      "rating": "5",
      "created_at": "2024-11-15T14:56:20.000000Z",
      "updated_at": "2024-11-15T14:56:20.000000Z"
    },
    [...]
  ]
}

Let’s deploy it!

Now that our REST API is fully functional with working endpoints, it’s time to deploy it to production on Upsun. Upsun will provide us with a scalable, managed hosting environment with built-in PostgreSQL and Redis support.

First let’s create our Upsun project.

upsun project:create

Let’s initialize a new git repository at the root of our project. Since the Laravel CLI already created one in the coffee-api folder, we’ll remove that first to avoid nested repositories.

rm -rf coffee-api/.git
git init .
git add .
git commit -m "Bootstrap our REST API"

Next, we’ll configure our local git repository to use Upsun as its remote origin. Replace [project id] with the ID from the previous step (project:create):

upsun project:set-remote [project id]

Creating the configuration

Before pushing our code to Upsun, we need to create a configuration file that defines our application’s infrastructure and deployment settings. This configuration will specify our PHP version, database requirements, and build/deploy processes. Create a new file called .upsun/config.yaml with the following configuration (explained below).

🤔
Because we are using PostgreSQL in our project, it is important to add pdo and pdo_pgsql to our PHP extensions!
.upsun/config.yaml
applications:
  coffee-api:
    source:
      root: "/coffee-api"
    type: "php:8.3"
    relationships:
      db: "postgresql:postgresql"
      cache: "redis:redis"
    mounts:
      "/.config":
        source: "storage"
        source_path: "config"

      "bootstrap/cache":
        source: "storage"
        source_path: "cache"

      "storage":
        source: "storage"
        source_path: "storage"
    web:
      locations:
        "/":
          passthru: "/index.php"
          root: "public"
    build:
      flavor: none
    dependencies:
      php:
        composer/composer: "^2"
    hooks:
      build: |
        set -eux
        composer --no-ansi --no-interaction install --no-progress --prefer-dist \
          --optimize-autoloader --no-dev        
      deploy: |
        set -eux
        mkdir -p storage/framework/sessions
        mkdir -p storage/framework/cache
        mkdir -p storage/framework/views
        php artisan migrate --force
        php artisan optimize:clear        
    runtime:
      extensions:
        - redis
        - pdo
        - pdo_pgsql
services:
  postgresql:
    type: postgresql:16
  redis:
    type: redis:7.0
routes:
  "https://api.{default}/":
    type: upstream
    upstream: "coffee-api:http"

For Laravel to properly configure itself on Upsun, we need to map the platform’s environment variables to ones that Laravel expects. Create a new .environment file in your project root with these essential configuration mappings:

.environment
export APP_KEY="base64:$PLATFORM_PROJECT_ENTROPY" # CHANGE IT!

# Set database environment variables
export DB_SCHEME="pgsql"
export DATABASE_URL="${DB_SCHEME}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_PATH}"

# Set Laravel-specific environment variables
export DB_CONNECTION="$DB_SCHEME"
export DB_DATABASE="$DB_PATH"

# Set Cache environment variables
export CACHE_STORE="redis"
export CACHE_URL="${CACHE_SCHEME}://${CACHE_HOST}:${CACHE_PORT}"

# Set Redis environment variables
export REDIS_URL="$CACHE_URL"
export QUEUE_CONNECTION="redis"
export SESSION_DRIVER="redis"

Let’s push!

With our configuration files in place and environment variables set up, we can now deploy our Laravel application to Upsun. The platform will automatically build our application, install dependencies, and set up the required services:

git add .
git commit -m "Add Upsun configuration"
upsun push

Let’s copy our data

If we test our API endpoint now, it will work but return empty results since database seeders don’t automatically run in production environments. We’ll need to populate our database with some initial data.

upsun url
Enter a number to open a URL
  [0] https://api.main-bvxea6i-fdhacb4dabmky.ch-1.platformsh.site/
  [1] http://api.main-bvxea6i-fdhacb4dabmky.ch-1.platformsh.site/

curl https://api.main-bvxea6i-fdhacb4dabmky.ch-1.platformsh.site/api/shops | jq
{
  "data": []
}

Let’s fix that by copying our local database content to the production environment. We can either copy just the shops table data or do a full database dump - both approaches will work. Let’s use our database GUI tool to export the data.

To connect to our Upsun database, we have two options:

  1. Run upsun db:sql for direct database access
  2. Create local SSH tunnels to access both the database and cache services
upsun tunnel:open
Are you sure you want to open SSH tunnel(s) to the environment main (type: production)? [Y/n] 

SSH tunnel opened to db at: pgsql://main:main@127.0.0.1:30000/main
SSH tunnel opened to cache at: redis://127.0.0.1:30001
...

Now that we have our SSH tunnel open, we can connect to the remote database using our favorite database GUI tool. Simply use the connection details provided by the tunnel command (default values shown here):

  • Host: 127.0.0.1
  • Port: 30000
  • Database: main
  • Username: main
  • Password: main

Once connected, we can run our INSERT queries to populate the remote database with our coffee shop data. After executing the queries, our remote API will have all the coffee shops available!

You can verify the data was copied successfully by checking the API response below:

curl https://api.main-bvxea6i-fdhacb4dabmky.ch-1.platformsh.site/api/shops | jq
{
  "data": [
    {
      "id": 1,
      "name": "Thompson LLC",
      "address": "39455 Austyn Passage",
      "city": "West Beverlyfurt",
      "state": "Wyoming",
      "zip": "61989-7988",
      "country": "Timor-Leste",
      "phone": "219-806-2419",
      "website": "http://www.hickle.net/",
      "rating": "2",
      "created_at": "2024-11-15T15:53:30.000000Z",
      "updated_at": "2024-11-15T15:53:30.000000Z"
    },
    [...]
  ]
}

Now that we’ve successfully deployed our API to Upsun and verified the data is accessible, we can shut down our local Laravel Sail development environment by running:

sail stop
#or
sail down # to remove volumes

Adding a domain

Now that our API is deployed and working, let’s make it accessible via a custom domain. This will give us a branded URL instead of the default Upsun hostname.

upsun domain:add remotefriendly.coffee

Next, you’ll need to configure your DNS settings with your domain provider. Add the following DNS records, replacing the hostname with your specific Upsun project hostname (which you can find quickly with the CLI command upsun environment:info edge_hostname -p PROJECT_ID -e PRODUCTION_ENVIRONMENT:

CNAME api.remotefriendly.coffee main-bvxea6i-[project id].[region].platformsh.site.
CNAME remotefriendly.coffee main-bvxea6i-[project id].[region].platformsh.site.
💡
Note: While Cloudflare supports CNAME flattening which allows using a CNAME for the root domain (@), many DNS providers require using A records instead. Check your provider’s documentation for their specific requirements regarding root domain configuration.

Final test!

Once your DNS changes propagate (which can take anywhere from a few minutes to 48 hours depending on your provider), Upsun will automatically provision and configure TLS certificates for your domain. If you’re using Cloudflare, you should be able to test the API endpoints immediately while waiting for DNS propagation:

curl https://api.remotefriendly.coffee/api/shops | jq
curl https://api.remotefriendly.coffee/api/shops/1 | jq

Our backend is now fully deployed & operational 🚀.

Summary

In this tutorial, we’ve accomplished several key objectives:

  1. Set up a complete local development environment with PHP, Composer, and Laravel Sail
  2. Created a new Laravel REST API project from scratch
  3. Implemented API endpoints for managing coffee shop data
  4. Configured and deployed the application to Upsun
  5. Added a custom domain with SSL/TLS support

What’s Next?

This REST API serves as the foundation for our coffee shop directory application. In the next article of this series, we’ll build a modern frontend using Next.js 15 that consumes this API. We’ll explore:

  • Setting up a Next.js 15 project
  • Implementing API integration
  • Leveraging Next.js’s powerful caching mechanisms
  • Deploying the frontend application

Stay tuned for the next part of this series where we’ll create an engaging user interface for browsing our coffee shop directory!

If you have any questions or run into issues, feel free to reach out in the comments below or join our community Discord server.

Happy coding! ☕️

Last updated on