Deploying Symfony on Hop3¶
This guide walks you through deploying a Symfony application on Hop3. By the end, you'll have a production-ready enterprise PHP application running on your own infrastructure.
Prerequisites¶
Before you begin, ensure you have:
- A Hop3 server - Follow the Installation Guide if you haven't set one up yet
- The Hop3 CLI - Installed on your local machine
- PHP 8.2+ - Install via your package manager
- Composer - PHP dependency manager
- Symfony CLI (optional) - From symfony.com/download
- Git - For version control and deployment
Verify your local setup:
Step 1: Create a New Symfony Application¶
Create a new Symfony web application:
Install the web application bundle:
Verify the project structure:
Step 2: Create Controllers¶
Create a welcome controller:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class WelcomeController extends AbstractController
{
#[Route('/', name: 'welcome')]
public function index(): Response
{
return new Response(<<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Welcome to Hop3</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #000000 0%, #1a1a1a 100%);
color: white;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 3rem; margin-bottom: 1rem; }
p { font-size: 1.25rem; opacity: 0.9; }
.symfony-logo { font-size: 4rem; margin-bottom: 1rem; }
a {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #7AB55C;
border-radius: 8px;
color: white;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="symfony-logo">🎵</div>
<h1>Hello from Hop3!</h1>
<p>Your Symfony application is running.</p>
<p>Current time: {$this->getCurrentTime()}</p>
<a href="/api/info">API Info</a>
</div>
</body>
</html>
HTML);
}
private function getCurrentTime(): string
{
return (new \DateTime())->format('c');
}
}
Create a health controller:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class HealthController extends AbstractController
{
#[Route('/up', name: 'up')]
public function up(): Response
{
return new Response('OK');
}
#[Route('/health', name: 'health')]
public function health(): JsonResponse
{
return $this->json([
'status' => 'ok',
'timestamp' => (new \DateTime())->format('c'),
'php_version' => PHP_VERSION,
'memory' => [
'usage' => round(memory_get_usage() / 1024 / 1024, 2) . 'MB',
'peak' => round(memory_get_peak_usage() / 1024 / 1024, 2) . 'MB',
],
]);
}
#[Route('/api/info', name: 'api_info')]
public function info(): JsonResponse
{
return $this->json([
'name' => 'hop3-tuto-symfony',
'version' => '1.0.0',
'symfony_version' => \Symfony\Component\HttpKernel\Kernel::VERSION,
'php_version' => PHP_VERSION,
'environment' => $_ENV['APP_ENV'] ?? 'prod',
]);
}
}
Step 3: Configure for Production¶
Configure the committed .env for production. Don't replace the Flex-generated .env — it defines defaults (DEFAULT_URI, MESSENGER_TRANSPORT_DSN, MAILER_DSN, …) that cache:clear resolves at compile time, and dropping them makes the production cache build fail with Environment variable not found. Edit only what we need: run in prod (so the post-install cache:clear doesn't load the dev-only DebugBundle that --no-dev excludes), give APP_SECRET a value, point DATABASE_URL at SQLite (self-contained — no DB server), and trust the local reverse proxy.
sed -i 's/^APP_ENV=dev/APP_ENV=prod/' .env
sed -i 's/^APP_SECRET=$/APP_SECRET=dev_secret_change_in_production/' .env
sed -i 's#^DATABASE_URL=.*#DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"#' .env
printf '\n# Trust the local Hop3 reverse proxy.\nTRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR\n' >> .env
echo "Configured .env for production"
Create production environment file:
Step 4: Enable Attribute Routing¶
Configure Symfony to discover routes from controller attributes:
Clear cache:
Verify routes are registered:
Step 5: Create Deployment Configuration¶
Create a Procfile¶
# Pre-build: Install dependencies and optimize
prebuild: composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-redis
# Pre-run: Clear and warm cache
prerun: php bin/console cache:clear --env=prod && php bin/console cache:warmup --env=prod
# Main web process
web: php -S 0.0.0.0:$PORT -t public
Production Server
For production, use PHP-FPM with Nginx or Apache. The built-in PHP server is used here for simplicity.
Create hop3.toml¶
[metadata]
id = "hop3-tuto-symfony"
version = "1.0.0"
title = "My Symfony Application"
[build]
before-build = [
"composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-redis"
]
packages = ["php", "php-pgsql", "php-mbstring", "php-xml", "php-intl", "composer"]
[run]
start = "php -S 0.0.0.0:$PORT -t public"
before-run = "php bin/console cache:clear --env=prod && php bin/console cache:warmup --env=prod"
[env]
# Generated once on the first deploy, persisted, and reused — never
# committed or rotated (ADR 046).
APP_SECRET = { generate = "hex", length = 16 }
APP_ENV = "prod"
APP_DEBUG = "0"
[port]
web = 8000
[healthcheck]
path = "/up"
timeout = 30
interval = 60
Verify the deployment files:
Step 6: Initialize Git Repository¶
Update .gitignore:
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
# OS files
.DS_Store
# IDE
.idea/
.vscode/
*.swp
Step 7: Deploy to Hop3¶
The following steps require a Hop3 server.
Initialize (First Time Only)¶
Set Environment Variables¶
Prepare for Deployment¶
Keep composer.lock (it pins your dependency versions for local work) and
remove the JS manifests so the builder doesn't mis-detect this as a
multi-language project:
- package.json / package-lock.json - prevent Node mis-detection
The server runs composer install --no-dev from the committed composer.lock for a reproducible build. The --ignore-platform-req=ext-redis flag is there because symfony/cache wants the phpredis extension at >= 6.1; this app doesn't use redis, so we let composer install without it.
rm -f package.json package-lock.json 2>/dev/null || true
git add -A && git commit -m "Prepare for deployment" || true
Deploy¶
Deploy the application (first deployment creates the app):
Set Hostname¶
Configure the hostname for nginx proxy:
Apply Configuration¶
Redeploy to apply the hostname configuration:
Wait for the application to start:
Verify Deployment¶
View logs:
# View logs
hop3 app logs --app hop3-tuto-symfony
# Your app will be available at:
# http://hop3-tuto-symfony.your-hop3-server.example.com
Managing Your Application¶
# Restart the application
hop3 app restart --app hop3-tuto-symfony
# Clear cache
hop3 app run --app hop3-tuto-symfony php bin/console cache:clear --env=prod
# View/set environment variables
hop3 env show --app hop3-tuto-symfony
hop3 env set --app hop3-tuto-symfony NEW_VAR=value
# Scale workers
hop3 ps scale --app hop3-tuto-symfony web=2
Advanced Configuration¶
Adding Doctrine ORM with PostgreSQL¶
Configure in .env:
Create an entity:
// src/Entity/User.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $email;
// Getters and setters...
}
Run migrations:
Adding API Platform¶
// src/Entity/User.php
use ApiPlatform\Metadata\ApiResource;
#[ORM\Entity]
#[ApiResource]
class User
{
// ...
}
Messenger (Async Processing)¶
Configure transport:
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Message\MyMessage': async
Add worker to Procfile:
Security & Authentication¶
Caching with Redis¶
# config/packages/cache.yaml
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'
Webpack Encore (Assets)¶
Update hop3.toml:
[build]
before-build = [
"composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-redis",
"npm install",
"npm run build"
]
packages = ["php", "nodejs", "npm"]
Troubleshooting¶
Cache Issues¶
Clear and warm up cache:
hop3 app run --app hop3-tuto-symfony php bin/console cache:clear --env=prod
hop3 app run --app hop3-tuto-symfony php bin/console cache:warmup --env=prod
Missing APP_SECRET¶
APP_SECRET is generated automatically on the first deploy (declared in hop3.toml [env]), so there's nothing to set by hand.
Database Connection Issues¶
Verify DATABASE_URL format:
Permission Issues¶
Ensure var/ directory is writable:
Example Files¶
Complete hop3.toml¶
[metadata]
id = "hop3-tuto-symfony"
version = "1.0.0"
title = "My Symfony Application"
[build]
before-build = [
"composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-redis",
"npm install",
"npm run build"
]
packages = ["php", "php-pgsql", "php-mbstring", "php-xml", "php-intl", "php-redis", "composer", "nodejs"]
[run]
start = "php -S 0.0.0.0:$PORT -t public"
before-run = "php bin/console doctrine:migrations:migrate --no-interaction && php bin/console cache:warmup --env=prod"
[env]
APP_SECRET = { generate = "hex", length = 16 }
APP_ENV = "prod"
APP_DEBUG = "0"
[port]
web = 8000
[healthcheck]
path = "/up"
timeout = 30
interval = 60
[[provider]]
name = "postgres"
plan = "standard"
[[provider]]
name = "redis"
plan = "basic"
Complete Procfile¶
prebuild: composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-redis && npm install && npm run build
prerun: php bin/console doctrine:migrations:migrate --no-interaction && php bin/console cache:warmup --env=prod
web: php -S 0.0.0.0:$PORT -t public
worker: php bin/console messenger:consume async --time-limit=3600
scheduler: while true; do php bin/console schedule:run; sleep 60; done