Skip to content

Spring Boot for PHP Developers: Parallels to Symfony and Your First REST API

Published on Jul 10, 2025 | approx. 2 min read |

As a PHP developer with Symfony experience, Java and Spring Boot often evoke mixed feelings. The reputation: complicated, slow to set up, lots of boilerplate. The reality with Spring Boot is different. The concepts are familiar — it is essentially Symfony, just in Java.

Concept Mapping: Symfony ↔ Spring Boot

Symfony Spring Boot Description
services.yaml @Component, @Service Service registration
#[Route] @RequestMapping, @GetMapping Routing
AbstractController @RestController Controller base
Doctrine ORM Spring Data JPA / Hibernate ORM
Repository interface JpaRepository<Entity, ID> Database access
EventSubscriber @EventListener Events
.env application.properties Configuration
Composer Maven / Gradle Dependency management
bin/console ./mvnw, ./gradlew CLI

Project Setup with Spring Initializr

The equivalent of composer create-project symfony/skeleton is start.spring.io. There you select:

  • Project: Maven or Gradle
  • Language: Java
  • Spring Boot Version: 3.x (current)
  • Dependencies: Spring Web, Spring Data JPA, H2 Database (for development)

The generated project has the following structure:

my-api/
├── src/main/java/com/example/myapi/
│   ├── MyApiApplication.java       # Entry point (like public/index.php)
│   ├── controller/
│   ├── service/
│   ├── repository/
│   └── entity/
├── src/main/resources/
│   └── application.properties      # Configuration (like .env)
├── src/test/java/                   # Tests
└── pom.xml                          # Composer equivalent

Dependency Injection — Just Like Symfony

Symfony and Spring Boot both use dependency injection as a core concept. In Symfony you write:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Service\ProductService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class ProductController extends AbstractController
{
    public function __construct(
        private readonly ProductService $productService
    ) {}
}

In Spring Boot it is almost identical:

package com.example.myapi.controller;

import com.example.myapi.service.ProductService;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ProductController {

    private final ProductService productService;

    // Constructor Injection — analogous to Symfony
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
}

Spring automatically detects classes annotated with @Service, @Component, and @Repository and injects them. Exactly like Symfony service autowiring.

REST Controller

Symfony:

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

#[Route('/api/products', name: 'api_products_')]
class ProductController extends AbstractController
{
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(): JsonResponse
    {
        return $this->json(['products' => []]);
    }

    #[Route('/{id}', name: 'show', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        return $this->json(['id' => $id]);
    }
}

Spring Boot:

package com.example.myapi.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public List<Product> list() {
        return productService.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> show(@PathVariable Long id) {
        return productService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody @Valid ProductDto dto) {
        Product created = productService.create(dto);
        return ResponseEntity.status(201).body(created);
    }
}

Entity and Repository (Analogous to Doctrine)

In Symfony you define a Doctrine entity:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private string $name;
}

In Spring Boot (JPA/Hibernate):

package com.example.myapi.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 255)
    private String name;

    // Getters and setters
    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

Repository

Symfony with Doctrine:

class ProductRepository extends ServiceEntityRepository
{
    public function findByCategory(string $category): array;
}

Spring Boot with JPA:

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {
    // Method automatically generated based on the name!
    List<Product> findByCategory(String category);
}

Spring Data JPA automatically generates the query from the method name — similar to Doctrine's QueryBuilder, but even more magical.

application.properties — The .env Equivalent

# Database connection (analogous to DATABASE_URL)
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=myuser
spring.datasource.password=mypassword

# JPA
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false

# Server port
server.port=8080

# Logging
logging.level.com.example=DEBUG

Tests — JUnit Instead of PHPUnit

package com.example.myapi.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void listProductsReturnsEmptyArray() throws Exception {
        mockMvc.perform(get("/api/products"))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json"))
            .andExpect(jsonPath("$").isArray());
    }
}

Conclusion

Spring Boot is surprisingly accessible for Symfony developers. Dependency injection, annotations instead of XML, an ORM with the repository pattern, and convention-based configuration — these are all familiar concepts. The main differences lie in static typing (which actually helps), the build process (Maven/Gradle instead of Composer), and deployment (a JAR file instead of PHP files on a web server).

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Comments

Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.