Deploying a Symfony App
What is Symfony?
Symfony is a mature, enterprise-grade PHP framework for building web applications and APIs. Known for its flexibility, reusability, and comprehensive ecosystem, Symfony is used by thousands of companies worldwide to build robust, scalable applications.
Key features include:
- Flexible MVC architecture with decoupled components
- Powerful routing system with advanced parameter conversion
- Doctrine ORM for database abstraction and object mapping
- Built-in security framework (authentication and authorization)
- Comprehensive form handling and validation
- Event dispatcher for loose coupling
- Service container and dependency injection
- Console component for CLI commands
- HTTP caching with reverse proxy support
- Cache abstraction (Redis, Memcached, File)
- Logging with Monolog integration
- API Platform for rapid API development
- Webpack Encore for frontend asset management
- Database migrations with Doctrine
- Testing utilities (PHPUnit, WebTestCase)
- Messenger for async processing
- Mailer component for email handling
- Code generation tools
- Comprehensive documentation
- Active community and ecosystem
Symfony is ideal for building enterprise web applications, RESTful APIs, single-page applications, microservices, and large-scale business systems.
Prerequisites
Before deploying a Symfony application to Klutch.sh, ensure you have:
- PHP 8.2 or later installed on your local machine
- Composer for dependency management
- Git and a GitHub account
- A Klutch.sh account with dashboard access
- PostgreSQL or MySQL for data persistence
- Node.js and npm for frontend assets (optional)
- Basic understanding of PHP and web architecture
Getting Started with Symfony
Step 1: Create a Symfony Project
Using Composer, create a new Symfony project:
composer create-project symfony/skeleton symfony-appcd symfony-appFor a full-stack application with forms, security, and other utilities:
composer create-project symfony/website-skeleton symfony-appcd symfony-appThis creates a project structure:
symfony-app/├── bin/│ └── console├── config/│ ├── packages/│ ├── routes.yaml│ └── services.yaml├── public/│ └── index.php├── src/│ ├── Controller/│ ├── Entity/│ ├── Repository/│ ├── Form/│ └── Kernel.php├── templates/├── tests/├── var/│ ├── cache/│ └── log/├── .env├── .env.local├── composer.json└── symfony.lockStep 2: Configure Environment
Create a .env file:
APP_ENV=devAPP_SECRET=your_secret_key_hereAPP_DEBUG=1
# DatabaseDATABASE_URL="postgresql://postgres:password@127.0.0.1:5432/symfony_db?serverVersion=15&charset=utf8"
# Or for MySQL:# DATABASE_URL="mysql://root:password@127.0.0.1:3306/symfony_db?serverVersion=8.0&charset=utf8mb4"
# RedisREDIS_URL=redis://127.0.0.1:6379
# MessengerMESSENGER_TRANSPORT_DSN=redis://127.0.0.1:6379/messagesStep 3: Create Database Entity
Create an Item entity:
php bin/console make:entity ItemThis generates src/Entity/Item.php:
<?php
namespace App\Entity;
use App\Repository\ItemRepository;use Doctrine\ORM\Mapping as ORM;use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ItemRepository::class)]#[ORM\Table(name: 'items')]class Item{ #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null;
#[ORM\Column(length: 100, unique: true)] #[Assert\NotBlank] #[Assert\Length(min: 1, max: 100)] private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)] #[Assert\Length(max: 500)] private ?string $description = null;
#[ORM\Column] #[Assert\NotBlank] #[Assert\Positive] private ?int $price = null;
#[ORM\Column] private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column] private ?\DateTimeImmutable $updatedAt = null;
public function __construct() { $this->createdAt = new \DateTimeImmutable(); $this->updatedAt = new \DateTimeImmutable(); }
public function getId(): ?int { return $this->id; }
public function getName(): ?string { return $this->name; }
public function setName(string $name): self { $this->name = $name; return $this; }
public function getDescription(): ?string { return $this->description; }
public function setDescription(?string $description): self { $this->description = $description; return $this; }
public function getPrice(): ?int { return $this->price; }
public function setPrice(int $price): self { $this->price = $price; return $this; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
public function setUpdatedAt(\DateTimeImmutable $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }}Step 4: Create Repository
Generate the repository:
php bin/console make:repository ItemRepositoryEdit src/Repository/ItemRepository.php:
<?php
namespace App\Repository;
use App\Entity\Item;use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;use Doctrine\Persistence\ManagerRegistry;
/** * @extends ServiceEntityRepository<Item> */class ItemRepository extends ServiceEntityRepository{ public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Item::class); }
public function findBySearch(string $query): array { return $this->createQueryBuilder('i') ->where('i.name LIKE :query') ->orWhere('i.description LIKE :query') ->setParameter('query', '%' . $query . '%') ->getQuery() ->getResult(); }
public function findAllPaginated(int $page = 1, int $limit = 10): array { return $this->createQueryBuilder('i') ->setFirstResult(($page - 1) * $limit) ->setMaxResults($limit) ->orderBy('i.createdAt', 'DESC') ->getQuery() ->getResult(); }}Step 5: Create API Controller
Generate a controller:
php bin/console make:controller Api/ItemControllerEdit src/Controller/Api/ItemController.php:
<?php
namespace App\Controller\Api;
use App\Entity\Item;use App\Repository\ItemRepository;use Doctrine\ORM\EntityManagerInterface;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api', name: 'api_')]class ItemController extends AbstractController{ public function __construct( private ItemRepository $itemRepository, private EntityManagerInterface $entityManager, private ValidatorInterface $validator, ) { }
#[Route('/health', name: 'health', methods: ['GET'])] public function health(): JsonResponse { return $this->json([ 'status' => 'healthy', 'service' => 'symfony-app' ]); }
#[Route('/items', name: 'items_list', methods: ['GET'])] public function list(Request $request): JsonResponse { $page = $request->query->getInt('page', 1); $limit = $request->query->getInt('limit', 10); $search = $request->query->getString('search', '');
$items = $search ? $this->itemRepository->findBySearch($search) : $this->itemRepository->findAllPaginated($page, $limit);
return $this->json([ 'data' => $items, 'page' => $page, 'limit' => $limit, 'total' => count($items) ]); }
#[Route('/items/{id}', name: 'items_show', methods: ['GET'])] public function show(Item $item): JsonResponse { return $this->json($item); }
#[Route('/items', name: 'items_create', methods: ['POST'])] public function create(Request $request): JsonResponse { $data = json_decode($request->getContent(), true);
$item = new Item(); $item->setName($data['name'] ?? ''); $item->setDescription($data['description'] ?? null); $item->setPrice($data['price'] ?? 0);
$errors = $this->validator->validate($item);
if (count($errors) > 0) { $errorMessages = []; foreach ($errors as $error) { $errorMessages[] = $error->getMessage(); } return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST); }
$this->entityManager->persist($item); $this->entityManager->flush();
return $this->json($item, Response::HTTP_CREATED); }
#[Route('/items/{id}', name: 'items_update', methods: ['PUT'])] public function update(Item $item, Request $request): JsonResponse { $data = json_decode($request->getContent(), true);
if (isset($data['name'])) { $item->setName($data['name']); } if (isset($data['description'])) { $item->setDescription($data['description']); } if (isset($data['price'])) { $item->setPrice($data['price']); } $item->setUpdatedAt(new \DateTimeImmutable());
$errors = $this->validator->validate($item);
if (count($errors) > 0) { $errorMessages = []; foreach ($errors as $error) { $errorMessages[] = $error->getMessage(); } return $this->json(['errors' => $errorMessages], Response::HTTP_BAD_REQUEST); }
$this->entityManager->flush();
return $this->json($item); }
#[Route('/items/{id}', name: 'items_delete', methods: ['DELETE'])] public function delete(Item $item): JsonResponse { $this->entityManager->remove($item); $this->entityManager->flush();
return $this->json(null, Response::HTTP_NO_CONTENT); }
#[Route('/items/stats', name: 'items_stats', methods: ['GET'])] public function stats(): JsonResponse { $total = count($this->itemRepository->findAll());
return $this->json([ 'total_items' => $total, 'timestamp' => time() ]); }}Step 6: Configure Routes
Edit config/routes.yaml:
api: resource: ../src/Controller/Api/ type: attribute prefix: /api
app: resource: ../src/Controller/ type: attributeStep 7: Create Database Migration
Generate migration from entities:
php bin/console make:migrationRun the migration:
php bin/console doctrine:migrations:migrateStep 8: Install Frontend Assets (Optional)
For asset management with Webpack Encore:
composer require symfony/webpack-encore-bundlenpm installStep 9: Build and Test Locally
# Install dependenciescomposer installnpm install # if using Webpack Encore
# Create databasephp bin/console doctrine:database:create
# Run migrationsphp bin/console doctrine:migrations:migrate
# Build assets (if using Webpack Encore)npm run build
# Start development serversymfony server:start -d# orphp -S localhost:8000 -t public
# Test the APIcurl http://localhost:8000/api/healthDeploying Without a Dockerfile
Klutch.sh uses Nixpacks to automatically detect and build your Symfony application from your source code.
Prepare Your Repository
- Initialize a Git repository and commit your code:
git initgit add .git commit -m "Initial Symfony app commit"- Create a
.gitignorefile:
/node_modules/public/bundles//public/build//var/cache//var/log//vendor.env.env.local.env.*.local.DS_Store*.log.idea/.vscode/- Create
.env.productionfor production configuration:
APP_ENV=prodAPP_DEBUG=falseDATABASE_URL="postgresql://user:pass@db-host:5432/symfony_db?serverVersion=15&charset=utf8"REDIS_URL=redis://redis-host:6379MESSENGER_TRANSPORT_DSN=redis://redis-host:6379/messages-
Ensure project includes:
composer.jsonin rootpublic/index.phpas entry point- Database migrations in
migrations/ - Proper environment variable templates in
.env
-
Push to GitHub:
git remote add origin https://github.com/YOUR_USERNAME/symfony-app.gitgit branch -M maingit push -u origin mainDeploy to Klutch.sh
-
Log in to Klutch.sh dashboard.
-
Click “Create a new project” and provide a project name.
-
Inside your project, click “Create a new app”.
-
Repository Configuration:
- Select your GitHub repository containing the Symfony app
- Select the branch to deploy (typically
main)
-
Traffic Settings:
- Select “HTTP” as the traffic type
-
Port Configuration:
- Set the internal port to 8080 (recommended for PHP applications)
-
Environment Variables: Set the following environment variables in the Klutch.sh dashboard:
APP_ENV: Set toprodfor productionAPP_DEBUG: Set tofalsefor productionDATABASE_URL: Your PostgreSQL or MySQL connection stringREDIS_URL: Your Redis connection URL (if using caching)MESSENGER_TRANSPORT_DSN: Your message broker URL (if using queues)APP_SECRET: A strong random secret key
-
Build and Start Commands (Optional): If you need to customize the build or start command, set these environment variables:
BUILD_COMMAND: Default runscomposer install && php bin/console doctrine:migrations:migrateSTART_COMMAND: Default isphp -S 0.0.0.0:8080 -t public
-
Region, Compute, and Instances:
- Choose your desired region for optimal latency
- Select compute resources (Starter for prototypes, Pro/Premium for production)
- Set the number of instances (start with 1-2, scale as needed)
-
Click “Create” to deploy. Klutch.sh will automatically build your application and deploy it.
-
Once deployment completes, your app will be accessible at
example-app.klutch.sh.
Verifying the Deployment
Test your deployed app:
curl https://example-app.klutch.sh/api/healthYou should receive:
{ "status": "healthy", "service": "symfony-app"}Access the API:
https://example-app.klutch.sh/api/itemsDeploying With a Dockerfile
If you prefer more control over your build environment, you can provide a custom Dockerfile. Klutch.sh automatically detects and uses a Dockerfile in your repository’s root directory.
Create a Multi-Stage Dockerfile
Create a Dockerfile in your project root:
# Build stageFROM composer:2.6 AS builder
WORKDIR /app
# Copy composer filesCOPY composer.json composer.lock ./
# Install PHP dependenciesRUN composer install --no-dev --optimize-autoloader
# Frontend build stage (if using Webpack Encore)FROM node:18-alpine AS node-builder
WORKDIR /app
COPY package.json package-lock.json ./RUN npm install
COPY . .RUN npm run build
# Runtime stageFROM php:8.2-fpm-alpine
WORKDIR /app
# Install required PHP extensions and toolsRUN apk add --no-cache \ postgresql-dev \ mysql-dev \ nginx \ curl \ supervisor
RUN docker-php-ext-install \ pdo \ pdo_mysql \ pdo_pgsql \ mysqli \ intl \ opcache
# Copy application filesCOPY --from=builder /app/vendor ./vendorCOPY --from=node-builder /app/public ./publicCOPY --chown=www-data:www-data . .
# Create cache and log directoriesRUN mkdir -p var/cache var/log && \ chown -R www-data:www-data var public
# Configure NginxRUN echo "server { \ listen 8080; \ server_name _; \ root /app/public; \ index index.php; \ client_max_body_size 100M; \ location / { \ try_files \\\$uri /index.php?\\\$args; \ } \ location ~ \.php\$ { \ fastcgi_pass 127.0.0.1:9000; \ fastcgi_index index.php; \ include fastcgi_params; \ fastcgi_param SCRIPT_FILENAME \\\$document_root\\\$fastcgi_script_name; \ } \}" > /etc/nginx/conf.d/default.conf
# Health checkHEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8080/api/health || exit 1
# Expose portEXPOSE 8080
# Create non-root userRUN addgroup -g 1000 symfony && \ adduser -D -u 1000 -G symfony symfony
USER symfony
# Start servicesCMD ["sh", "-c", "php-fpm & nginx -g 'daemon off;'"]Deploy the Dockerfile Version
- Push your code with the Dockerfile to GitHub:
git add Dockerfilegit commit -m "Add Dockerfile for custom build"git push-
Log in to Klutch.sh dashboard.
-
- Select your GitHub repository and branch
- Set traffic type to “HTTP”
- Set the internal port to 8080
- Add environment variables
- Click “Create”
-
Klutch.sh will automatically detect your Dockerfile and use it for building and deployment.
Database Configuration
PostgreSQL Setup
PostgreSQL is recommended for Symfony applications. Configure in .env:
DATABASE_URL="postgresql://user:password@host:5432/symfony_db?serverVersion=15&charset=utf8"MySQL Setup
Configure MySQL in .env:
DATABASE_URL="mysql://user:password@host:3306/symfony_db?serverVersion=8.0&charset=utf8mb4"Running Migrations
Create a migration from entities:
php bin/console make:migrationRun migrations during deployment or manually:
php bin/console doctrine:migrations:migrateSecurity Configuration
Authentication Setup
Install security bundle:
composer require symfony/security-bundleConfigure in config/packages/security.yaml:
security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto
providers: app_user_provider: entity: class: App\Entity\User property: email
firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false
main: lazy: true provider: app_user_provider custom_authenticators: - App\Security\ApiTokenAuthenticator
access_control: - { path: ^/api, roles: IS_AUTHENTICATED } - { path: ^/api/health, roles: PUBLIC_ACCESS }Caching Configuration
Redis Setup
Configure Redis in config/packages/cache.yaml:
framework: cache: default: cache.adapter.redis pools: cache.adapter.redis: adapter: cache.adapter.redis public: false tags: trueConfigure in .env:
REDIS_URL=redis://127.0.0.1:6379Using Cache
use Symfony\Contracts\Cache\CacheInterface;
public function getCachedData(CacheInterface $cache){ return $cache->get('items_cache', function (ItemInterface $item) { $item->expiresAfter(3600); return $this->fetchItems(); });}Messenger and Async Processing
Setup Messenger
Install messenger:
composer require symfony/messengerConfigure in .env:
MESSENGER_TRANSPORT_DSN=redis://127.0.0.1:6379/messagesCreate a message:
php bin/console make:message ProcessItemCreate a handler:
php bin/console make:message-handler ProcessItemMessageHandlerDispatch a message:
use App\Message\ProcessItem;use Symfony\Component\Messenger\MessageBusInterface;
public function create(MessageBusInterface $bus){ $bus->dispatch(new ProcessItem($item));}Logging and Monitoring
Configure Logging
Edit config/packages/monolog.yaml:
monolog: handlers: main: type: rotating_file path: "%kernel.logs_dir%/%kernel.environment%.log" max_files: 10 level: info
console: type: console process_psr_3_messages: falseUsing Logging
use Psr\Log\LoggerInterface;
public function process(LoggerInterface $logger){ $logger->info('Processing item', ['id' => $item->getId()]); $logger->error('Error occurred', ['error' => $exception->getMessage()]);}Persistent Storage for Logs and Uploads
Adding Persistent Volume
- In the Klutch.sh app dashboard, navigate to “Persistent Storage” or “Volumes”
- Click “Add Volume”
- Set the mount path:
/app/var(for logs and cache) - Set the size based on your needs (e.g., 10 GB)
- Save and redeploy
Configure Log Path
Configure logging to use persistent storage:
monolog: handlers: main: type: rotating_file path: "/app/var/log/%kernel.environment%.log"Custom Domains
To serve your Symfony application from a custom domain:
- In the Klutch.sh app dashboard, navigate to “Custom Domains”
- Click “Add Custom Domain”
- Enter your domain (e.g.,
api.example.com) - Follow the DNS configuration instructions provided
Example DNS configuration:
api.example.com CNAME example-app.klutch.shUpdate APP_URL environment variable if needed:
APP_URL=https://api.example.comTroubleshooting
Issue 1: Database Migration Fails
Problem: Migrations fail during deployment with connection errors.
Solution:
- Verify DATABASE_URL is correctly formatted
- Ensure database server is running and accessible
- Check database user has CREATE/ALTER TABLE permissions
- Review migration file syntax for errors
- Test connection string format with database client
- Check firewall rules allow the connection
Issue 2: Cache or Session Errors
Problem: Cache operations fail or sessions are not persisting.
Solution:
- Verify REDIS_URL is correctly configured
- Ensure Redis server is running and accessible
- Check Redis connection string format
- Verify firewall rules allow Redis connection
- Monitor Redis server logs for errors
Issue 3: Slow Application Performance
Problem: Application responds slowly to requests.
Solution:
- Enable caching for frequently accessed data
- Optimize database queries and add indexes
- Use eager loading in Doctrine queries
- Enable HTTP caching headers
- Monitor performance metrics in dashboard
- Consider scaling to additional instances
Issue 4: File Upload Permission Denied
Problem: File uploads fail with permission errors.
Solution:
- Mount persistent storage for upload directory
- Ensure web server user has write permissions
- Check disk space availability
- Verify file size limits in php.ini
- Review upload directory permissions
Issue 5: Memory or Disk Space Issues
Problem: Application crashes due to resource limits.
Solution:
- Increase instance compute resources
- Increase persistent storage size
- Optimize database queries
- Clean up old cache files
- Monitor resource usage via dashboard
Best Practices for Production Deployment
-
Environment Configuration: Use
.envfor sensitive dataAPP_ENV=prodAPP_SECRET={STRONG_SECRET}DATABASE_PASSWORD={STRONG_PASSWORD} -
Enable Caching: Use Redis for performance
framework:cache:default: cache.adapter.redis -
Database Optimization: Add indexes and optimize queries
$query = $this->itemRepository->findBySearch($search); -
Input Validation: Always validate user input
$errors = $this->validator->validate($item); -
Error Handling: Don’t expose sensitive information
if (!$item) {throw $this->createNotFoundException('Item not found');} -
Security: Enable HTTPS and security headers
framework:http_method_override: false -
Logging: Log important events and errors
$logger->info('Item created', ['id' => $item->getId()]); -
Async Processing: Use messenger for long-running tasks
$bus->dispatch(new ProcessItem($item)); -
Rate Limiting: Implement rate limiting for APIs
framework:rate_limiter:api:policy: 'sliding_window'limit: 100interval: '1 minute' -
Monitoring: Configure health checks
if ($item->getId() > 0) {return $this->json(['status' => 'ok']);}
Resources
- Symfony Official Website
- Symfony Documentation
- Doctrine ORM Guide
- Routing Documentation
- Security Documentation
- Messenger Guide
- API Platform Documentation
Conclusion
Deploying Symfony applications to Klutch.sh provides a robust, scalable platform for building and running enterprise-grade PHP applications. Symfony’s flexibility, comprehensive ecosystem, and best-in-class tools make it ideal for complex, mission-critical systems.
Key takeaways:
- Use Nixpacks for quick deployments with automatic PHP detection
- Use Docker for complete control over PHP extensions and dependencies
- Leverage Doctrine ORM for efficient database operations
- Implement proper security with authentication and authorization
- Use caching with Redis for improved performance
- Configure persistent storage for logs and application data
- Enable HTTPS and security best practices in production
- Implement structured logging for monitoring and debugging
- Use messenger for async processing of long-running tasks
- Monitor application health and performance metrics
For additional help, refer to the Symfony documentation or Klutch.sh support resources.