Building PHP Applications with the Symfony Framework
in 2026
ConFoo, Montreal, Canada - February 26th, 2026
© David Buchmann
David Buchmann - david@liip.ch
PHP Engineer, Liip SA, Switzerland
Code Quality in the Age of LLMs
- LLMs [marketing speech: AI] underline the need for robust coding techniques
- Defending against human and algorithm errors
- Documentation is (still) important
- Robust code is (still) relevant
- Testing is (still) a thing
Dokumentation
- What does the application do?
- What is the core architecture?
- How do I set it up for local development?
- Available commands (database, fixtures, tests ...)?
- How does deployment work?
- Where to find it online?
- Detailed architectural decisions in ADR documents
Automate as much of your documentation as possible:
Dockerfile, composer scripts, Makefile, deployment
PHP Language Features
Strict Typing
/**
* @param string $message the message
* @return bool
*/
public function confirm($question)
{
...
}
Please type your methods and properties
public function confirm(string $question): bool
{
...
}
- Intent clearly declared
- No drift between documentation and code
- No uncertainty - errors discovered immediately
Union Types
public function get(string|Uuid $id): MyModel
{
if (is_string($id)) {
trigger_error(
'Pass Uuid instance for the id.',
E_USER_DEPRECATED
);
$id = new Uuid($id);
}
}
Use Value Objects
- Email is not any string
- Validation central and upfront when creating
- Intention in parameters is explicit
- Proper model of your reality
Value Object example
final class Money {
public function __construct(
private int $amount,
private string $currency,
) {}
public function setAmountInCurrency(
int $amount,
string $currency
): self {
$this->amount = $amount;
$this->currency = $currency;
}
}
Enums
- A "value object" without identity
- More semantic than constants passed as strings
- Only valid values are possible
- Backed enum can easily convert from / to string
enum QuestionType: string
{
case Boolean = 'boolean';
case Checkboxes = 'checkboxes';
case Numeric = 'numeric';
case Range = 'range';
}
Data Transfer Objects (DTO)
- Also replaces array with semantic object: Robustness and discoverability
- DTO aimed at communicating changes in application
- Typically mutable, validation once all values set
- E.g. represent fields of a form
- For specific use case, not complete domain model
PHP Named Arguments
Benefits
- Avoid mistakes because of wrong order
- No need to specify previous optional arguments
Problem: Argument name becomes part of the BC promise
Named Arguments: Readability
setcookie(
'session',
'myvalue',
0,
'',
'',
false,
true
);
Named Arguments: Readability
setcookie(
name: 'session',
value: 'myvalue',
expires_or_options: 0,
path: '',
domain: '',
secure: false,
httponly: true,
);
Named Arguments: Readability
setcookie(
name: 'session',
value: 'myvalue',
expires_or_options: 0, // default
path: '', // default
domain: '', //default
secure: false, // default
httponly: true,
);
Named Arguments: Skip default
setcookie(
name: 'session',
value: 'myvalue',
expires_or_options: 0,
path: '',
domain: '',
secure: false,
httponly: true,
);
setcookie(
name: 'session',
value: 'myvalue',
httponly: true,
);
// skip default arguments
Annotations => Attributes
PHP Attributes
class Controller
{
- /** @Route('/home') */
+ #[Route('/home')]
public function index()
{
...
}
}
Attributes and named parameters
Attributes are PHP code: syntax errors, typo detection, constants, named arguments etc
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[Route(
path: '/home',
name: 'homepage',
methods: [Request::METHOD_GET]],
)]
public function index(): Response
{...
Named argument in Symfony Validator
Symfony validator attributes with "options" array:
#[Assert\Length([
'min' => 8,
'max' => 50,
'minMessage' => 'Password needs at least {{ limit }} chars',
'maxMessage' => 'Password cannot exceed {{ limit }} chars',
])]
Named argument in Symfony Validator
Validator nowadays also has named parameters.
#[Assert\Length(
min: 8,
max: 50,
minMessage: 'Password needs at least {{ limit }} chars',
maxMessage: 'Password cannot exceed {{ limit }} chars',
)]
Tools
Tools in your CI
- Runtime tests
- Static analysis
- Code Style
- Run on every push
- Do not merge when not green
- If main branch is red, fix immediately
phpunit
- Testing strategy
- Smoke tests
- Functional tests
- Unit tests
- Mock as little as possible
- Coverage? Investigate mutation testing
- Behat: Deterministic tests directly from user stories
phpunit testing strategy
Smoke
- Simulate web request
- Quick to write
- Not very useful for debugging
Functional
- Fetch service from container
- Mock clients to external service
- Use fixtures in database (but don't mock database)
Unit
- Very fast
- For business logic
- Test all edge cases
- Still don't mock DTO / Value Objects
phpstan
The missing «compiler» for PHP - discovers mistakes in CI
- Static analysis - parses and analyses your code
- The more typed (or annotated) code is, the better
- Plugins for Symfony, Doctrine, Laravel, ...
- You can write custom plugins
Alternatives: Psalm, Phan, Exakat
phpstan levels
- Store baseline to start working with legacy code
- Disable or enable rules
- Rule levels from 0 to 10 to gradually improve
- Run it in the CI
phpstan levels
- 0: Unknown classes / functions / methods (on $this), wrong number of arguments, always undefined variables
- 1: Possibly undefined variables, unknown magic methods/properties with __call or __get
- 2: Unkown methods on other variables, validate phpdoc
- 3: Return types, types assigned to properties
- 4: Basic dead code: Always false instanceof / type checks, dead else, code after return
phpstan levels
- 5: Type of arguments passed to methods / functions
- 6: Missing type hints
- 7: Partially wrong union types
- 8: Method call / property access on nullable
- 9: Complain if mixed is passed to anything else than mixed
- 10: Errors for missing type (implicit mixed)
rector
- Deterministic refactorings of constructs
- Detect outdated constructs, promote consistent patterns
- Use new PHP features
- Use new Symfony constructs
- Some changes are risky, be sure to have tests / verify application to discover regressions
Alternatives: RefactorPHP, Phpactor
phpmd
PHP mess detector - identify code with «smells»
- Overly long methods
- Overly long classes
- Too many parameters
- Too many public methods
- Excessive complexity
- Unused code
phpmd: cyclomatic complexity
function getDiscount(User $user): int
{
if ($user->isLoggedIn()) {
if ($user->isPremium()) {
return 20;
}
return 10;
}
return 0;
}
phpmd: early return
function getDiscount(User $user): int
{
if (!$user->isLoggedIn()) {
return 0;
}
if ($user->isPremium()) {
return 20;
}
return 10;
}
php-cs-fixer
- Consistent code style
- Reduce visual overload when reading
- Pick ruleset to avoid arguing over every detail
- Can add custom rules
Alternatives: PHP_CodeSniffer, Prettier (with PHP Plugin), phpfmt
Symfony Framework
Symfony Maker
- Deterministic template bsaed generator
- Interactive definition of your needs, e.g. entity properties
- If you use LLM: Ignore this one
- If you don't: Use it to save manually writing boilerplate
- Add your own rulesets for frequent constructs to speed up and promote consistency
Symfony Autowiring
- Autowiring automatically plugs instance to your service
- No lengthy service definitions that go out of sync
- When multiple services of an interface, name based matching
(Can get rather magic, love it or hate don't use it)
Symfony Autowiring
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
githubClient:
base_uri: 'https://api.github.com'
stripeClient:
base_uri: 'https://api.stripe.com'
Symfony Autowiring
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiService
{
public function __construct(
HttpClientInterface $githubClient,
HttpClientInterface $stripeClient,
) {}
}
Symfony Autoconfigure
- Automatically tags forms, twig extensions etc
- Based on implemented interfaces
- Can use the attributes if need to customize
Value Resolvers in Controller methods
- #[MapQueryParameter] (backed enum, array, bool, float, int, string, uid) - optionally with filter
- #[MapQueryString] with a dto class that matches the query parameters - supports validation
- #[MapRequestPayload] for body
- #[MapEntity] automatically load doctrine entities - configure if not default id or other method
- UserInterface
(or your custom user class and #[CurrentUser])
Value Resolvers
- Magic for controller done with ValueResolver
- You can add your own to expand the functionality
- Pass parameters e.g. to MapEntity to specify the parameter name and field name, or repository method to use
Some (not yet all) of this also in Symfony Command
Conclusions
Code Quality in the Age of LLMs
- Understanding a codebase helps both humans and algorithms
- Clarity of code prevents errors
- PHP has been improved to allow for strict and robust code
- Symfony has been improved to reduce boilerplate
Shoutouts
- Daniel Scherzer: PHP 8.5: New Features from the Source
- Chris Hartjes : Writing Testable PHP Code In An Uncaring World (watch the recording)
Thank you!
https://phpc.social/@dbu
David Buchmann, Liip SA