The post PHP RAG Tutorial: Build an AI Agent with Neuron AI appeared first on International PHP Conference.
]]>Note: This podcast and video were created using AI. They are based on the original content and technical insights provided by the author of the blog post.
Implementing Retrieval-Augmented Generation (RAG) is often the first wall PHP developers hit when moving beyond simple chat scripts. While the concept of “giving an LLM access to your own data” is straightforward, the tasks required to make it work reliably in a PHP environment can be frustrating. You have to manage document parsing, vector embeddings, storage in a vector database, and the final prompt orchestration. Most developers end up trying to glue several disparate libraries together, only to find that the resulting system is brittle and hard to maintain.
All news about PHP and web development
Neuron was designed to eliminate this friction. It provides a built-in RAG module that handles the heavy lifting of the data pipeline, allowing you to focus on your agent’s logic rather than the mechanics of vector management and similarity search. In a typical scenario, like building a support agent that needs to “read” your company’s internal documentation, you don’t want to manually handle the chunking of text or the API calls to OpenAI’s embedding models. Neuron abstracts these into a fluent workflow where you define a “Data Source,” and the framework ensures the most relevant snippets of information are injected into the agent’s context window at runtime.
Retrieval Augmented Generation breaks down into three critical components that work in harmony to solve a fundamental problem in AI: how do we give language models access to specific, up-to-date, or proprietary information that wasn’t part of their original training data?
The “G” part of the RAG acronym is straightforward. We’re talking about Generative AI models like GPT, Claude, Gemini, or any large language model that can produce human-like text responses. These models are incredibly powerful, but they have a significant limitation: they only know what they were trained on, and that knowledge has a cutoff date. They can’t access your company’s internal documents, your personal notes, or real-time information from your databases.
This is where the “Retrieval Augmented” component becomes transformative. Instead of relying solely on the model’s pre-trained knowledge, we augment its capabilities by retrieving relevant information from external sources at the moment of generation. Think of it as giving your AI agent a research assistant that can instantly find and present relevant context before answering any question.
Below you can see an example of how this process should work:

To understand how retrieval works in practice, we need to dive into embeddings—a concept that initially seems abstract but becomes intuitive once you see it in action. An embedding is essentially a mathematical representation of text, images, or any data converted into a list of numbers called a vector. What makes this powerful is that similar concepts end up with similar vectors, creating a mathematical space where related ideas cluster together.

When I first started working with Neuron AI, I was amazed by how this actually works in practice. Imagine you have thousands of documents—customer support tickets, product manuals, internal wikis, research papers. Traditional keyword search would require exact matches or clever Boolean logic to find relevant information. But with embeddings, you can ask a question like “How do I troubleshoot connection issues?” and the system will find documents about network problems, authentication failures, and server timeouts, even if those documents never use the exact phrase “connection issues.”
The process works by converting both your question and all your documents into these mathematical vectors. The system then calculates which document vectors are closest to your question vector in this multi-dimensional space. It’s like having a librarian who understands the meaning and context of your request, not just the literal words you used.
The conceptual understanding of RAG is one thing; actually building a working system is another challenge entirely. This is where the complexity really emerges, and it’s why Neuron is such a valuable tool for PHP developers entering this space.
The ecosystem involves multiple moving parts: you need to chunk your documents effectively, generate embeddings using appropriate models, store and index those embeddings in a vector database, implement semantic search functionality, and then orchestrate the retrieval and generation process seamlessly.

Each of these steps involves technical decisions that can significantly impact your agent’s performance (speed and quality of responses). How do you split long documents into meaningful chunks? Which embedding model works best for your domain? How do you handle updates to your knowledge base? How do you balance retrieval accuracy with response speed? These questions become more pressing when you’re building production systems that need to scale and perform reliably.
In the detailed implementation guide that follows, we’ll explore how Neuron simplifies this complex orchestration, providing PHP developers with tools and patterns that make RAG agent development both accessible and powerful.
To get started, you can install the core framework and the RAG components via Composer:
composer require neuron-core/neuron-ai
To create a RAG, Neuron provides you with a dedicated class you can extend to orchestrate the necessary components such as the AI provider, vector store, and embeddings provider.
First, let’s create the RAG class:
namespace App\Neuron;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\Anthropic\Anthropic;
use NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface;
use NeuronAI\RAG\Embeddings\OpenAIEmbeddingsProvider;
use NeuronAI\RAG\RAG;
use NeuronAI\RAG\VectorStore\FileVectorStore;
use NeuronAI\RAG\VectorStore\VectorStoreInterface;
class MyRAG extends RAG
{
protected function provider(): AIProviderInterface
{
return new Anthropic(
key: 'ANTHROPIC_API_KEY',
model: 'ANTHROPIC_MODEL',
);
}
protected function embeddings(): EmbeddingsProviderInterface
{
return new VoyageEmbeddingsProvider(
key: 'VOYAGE_API_KEY',
model: 'VOYAGE_MODEL'
);
}
protected function vectorStore(): VectorStoreInterface
{
return new FileVectorStore(
directory: __DIR__,
name: 'demo'
);
}
}
In the example above, we provided the RAG with a connection to:
Be sure to provide the appropriate information to connect with these services. You have plenty of options for each of these components. You can use local systems or managed services, so feel free to explore the documentation to choose your preferred ones.
All news about PHP and web development
At this stage, the vector store behind our RAG agent is empty. If we send a prompt to the agent, it will be able to respond, leveraging only the underlying LLM training data.
use NeuronAI\Chat\Messages\UserMessage;
$response = MyRAG::make()
->chat(
new UserMessage('What size is the door handle on our top car model?')
);
echo $response->getContent();
// I don't really know specifically about your top car model. Do you want to provide me with additional information?
We need to feed the RAG with some knowledge so that it’s able to respond to questions about private information outside its default training data.
To build a structured AI application, you need the ability to convert all the information you have into text so you can generate embeddings, save them into a vector store, and then feed your Agent to answer the user’s questions.

Neuron has a dedicated module to simplify this process. In order to answer the previous question (What size is the door handle on our top car model?), we can feed the RAG with documents (Markdown files, PDFs, HTML pages, etc.) containing such information.
You can do it in just a few lines of code:
use NeuronAI\RAG\DataLoader\FileDataLoader;
// Use the file data loader component to process documents
$documents = FileDataLoader::for(__DIR__)
->addReader('pdf', new \NeuronAI\RAG\DataLoader\PdfReader())
->addReader(['html', 'xhtml'], new \NeuronAI\RAG\DataLoader\HtmlReader())
->getDocuments();
MyRAG::make()->addDocuments($documents);
As you can see from the example above, you can just point the data loader to a directory containing all the files you want to load into the vector store, and it automatically does the following:
It’s just an example to demonstrate how you can create a complete data pipeline for your agentic application in 5 lines of code. You can learn more about the extensibility and customization opportunities for readers and splitters here.
Imagine having previously populated the vector store with the knowledge base you want to connect to the RAG agent, and now you want to ask questions. To start the execution of a RAG, you call the chat() method:
use App\Neuron\MyRAG;
use NeuronAI\Chat\Messages\UserMessage;
$response = MyRAG::make()->chat(
new UserMessage('What size is the door handle on our top car model?')
);
echo $response->getContent();
// Based on 2025 sales results, the top car model in your catalog is XXX...
Many of the Agents you build with NeuronAI will contain multiple steps with multiple invocations of LLM calls, tool usage, access to external memories, etc. As these applications get more and more complex, it becomes crucial to be able to inspect exactly what your agent is doing and why. Why is the model taking certain decisions? What data is the model reacting to?
The Inspector team designed Neuron AI with built-in observability features so you can monitor AI agents while running, helping you maintain production-grade implementations with confidence.
To start monitoring your agentic systems, you need to add the INSPECTOR_INGESTION_KEY variable in your application environment file. Authenticate on Inspector.dev to create a new one.
INSPECTOR_INGESTION_KEY=nwse877auxxxxxxxxxxxxxxxxxxxxxxxxxxxx
When your agents are being executed, you will see the details of their internal steps on the Inspector dashboard.

All news about PHP and web development
The complexity of orchestrating embeddings, vector databases, and language models might seem a bit daunting, but remember that every expert was once a beginner wrestling with these same concepts.
The next step is to dive into the practical implementation. Neuron AI framework is designed specifically to bridge the gap between RAG theory and production-ready agents, handling the complex integrations while giving you the flexibility to customize the behavior for your specific use case. Start building your first RAG agent today and discover how powerful context-aware AI can transform your applications.
Whether you’re just getting started with AI agents or looking to take your skills to the next level, the resources in [1], [2], and [3] provide practical guidance to help you move forward.
[1] Repository
[2] Newsletter
[3] Start With AI Agents In PHP
The post PHP RAG Tutorial: Build an AI Agent with Neuron AI appeared first on International PHP Conference.
]]>The post Strategy vs Decorator in PHP: Choosing the Right Design Pattern appeared first on International PHP Conference.
]]>More so, senior developers know that there’s no need to reinvent the wheel at every step. They know that design patterns exist precisely for this reason: to produce elegant and maintainable solutions. However, there’s a catch. Choose wrong, and you’ll find yourself trapped in a labyrinth even worse than what you had to begin with.
All news about PHP and web development
In this article, I will discuss the use of two patterns, Strategy and Decorator, which, at first glance, seem to solve the same problem. But if you look closely, you’ll discover that each one has its particular scope. Let’s explore how you can use them to make your code maintainable over the long run through real-world examples and some tips on when to use each one.
To begin with, they both belong in the behavioral category. They deal with problems related to the proper distribution of responsibilities among objects. Other patterns deal with different sets of problems, such as those in the creational category, which provide different approaches to how to create new instances of classes, or those in the structural category, which help when the issue is about combining objects into large structures.
Back to our scope, both the Strategy and Decorator patterns address the challenge of determining, at runtime, which logic should be applied to a particular case. This gives you a first clue about when these patterns are useful: whenever multiple approaches are available and the appropriate choice cannot be determined until the application is actually running.
Strategy is about choosing one possibility from a pool of interchangeable options, while Decorator is about choosing many composable possibilities. In other words, you can think of Strategy as a big “or” and of Decorator as a big “and”. Sounds confusing? Let’s explain it with some examples.
Say you have a scenario like this: you are developing an application for a wealth management firm. They manage a portfolio of financial assets on behalf of their clients. You have a data model that looks roughly like this:
<?php
abstract readonly class Security
{
public string $isin;
public function __construct(string $isin)
{
$this->isin = $isin;
}
}
<?php
readonly class Stock extends Security
{
public string $ticker;
public function __construct(string $ticker, string $isin)
{
parent::__construct($isin);
$this->ticker = $ticker;
}
}
<?php
readonly class Bond extends Security
{
public string $description;
public function __construct(string $isin, string $description)
{
parent::__construct($isin);
$this->description = $description;
}
}
<?php
readonly class MutualFund extends Security
{
public string $name;
public function __construct(string $isin, string $name)
{
parent::__construct($isin);
$this->name = $name;
}
}
To produce a particular report, your application needs to know the prices the assets held by clients had at random past dates. Sounds pretty simple, doesn’t it? It’s just about iterating over the collection of assets and, for each one, fetching its price at the specific date.
But you know what they say… the devil’s in the details.
Let’s assume there are three APIs you can query to get the information you need, but not everyone will have data for every security. To make things a little more complicated (and realistic), there’s no rule to determine which one will. And, of course, each API has a different contract you need to abide by.
Let’s take a simple approach: query each API until you get a positive result. Since you can’t know in advance which one will be a hit, the order is not really relevant here. A naive first attempt at it could look like this:
<?php
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;
function getPriceFor(Security $security, DateTimeInterface $date): ?SecurityPrice
{
// Try to fetch price from first API
if ($price) {
return $price;
}
// Try to fetch price from second API
if ($price) {
return $price;
}
// Try to fetch price from third API
if ($price) {
return $price;
}
return null;
}
And it’ll work. But, as things move forward, it will get messy. For starters, you’ll have too much responsibility buried inside the getPriceFor function, making the code rather difficult to read and reason about. You could work around this by extracting the logic of interacting with each API into its own private method. That would be a step in the right direction, but it won’t make much progress.
More importantly, you need to think about the future: what will happen when a new data source becomes available? Or when an API changes its contract? Or you find out that one of them produces the expected result 85% of the time? In any of these situations, you’ll have to revisit the code you wrote and tested months ago.
And that’s something you definitely don’t want to do. Once something is working, you want to leave it as it is and forget about it. In fact, writing those tests in the first place is not going to be easy (or pleasant). This is the exact scenario where the Strategy pattern comes to the rescue: it provides a generalisation you can extend indefinitely.
All news about PHP and web development
In practice, this means having a class to interact with each API and giving your function a collection of objects it can use without worrying about the little details.
Start by defining an interface that all concrete strategies will implement:
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
interface SecurityPriceFetcher
{
function fetch(Security $security, DateTimeInterface $date): float;
}
Now each API integration becomes a class of its own, encapsulating its specific interaction logic:
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
class FirstAPIPriceFetcher implements SecurityPriceFetcher
{
function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
// Code to interact with the first API
}
}
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
class SecondAPIPriceFetcher implements SecurityPriceFetcher
{
function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
// Code to interact with the second API
}
}
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
class ThirdAPIPriceFetcher implements SecurityPriceFetcher
{
function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
// Code to interact with the third API
}
}
You could also create these starting with a base abstract class instead of an interface; it doesn’t really matter that much. The important thing is they are basically performing the same action, though with different approaches.
With these strategies at hand, the orchestration function becomes much cleaner and generic:
<?php
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;
function getPriceFor(Security $security, DateTimeInterface $date, array $fetchers): ?SecurityPrice
{
foreach ($fetchers as $fetcher) {
$price = $fetcher->fetch($security, $date);
if ($price !== null) {
return $price;
}
}
return null;
}
And the calling site is explicit and readable:
echo getPriceFor(
new Bond("AA11232H", "Some government-issued security"),
new DateTimeImmutable(),
[
new FirstAPIPriceFetcher(),
new SecondAPIPriceFetcher(),
new ThirdAPIPriceFetcher(),
]
)->value;
And then, when a new API becomes available, all you have to do is:
1. Create the new class
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
class FourthAPIPriceFetcher implements SecurityPriceFetcher
{
function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
// Code to interact with the third API
}
}
2. Add a new instance of such a class to the array passed to the function getPriceFor
echo getPriceFor(
new Bond("AA11232H", "Some government-issued security"),
new DateTimeImmutable(),
[
new FirstAPIPriceFetcher(),
new SecondAPIPriceFetcher(),
new ThirdAPIPriceFetcher(),
new FourthAPIPriceFetcher(),
]
)->value;
Also, should you realize that the calling order is not ideal, it’s just a matter of reorganizing the array and voilà:
echo getPriceFor(
new Bond("AA11232H", "Some government-issued security"),
new DateTimeImmutable(),
[
new ThirdAPIPriceFetcher(),
new FirstAPIPriceFetcher(),
new FourthAPIPriceFetcher(),
new SecondAPIPriceFetcher(),
]
)->value;
Now things look more promising, don’t they? And, on top of that, you get to write separate tests for each strategy and the orchestration function:
<?php
use Mauro\Strategy\MutualFund;
use Mauro\Strategy\SecurityPrice;
use Mauro\Strategy\SecurityPriceFetcher;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
require_once '../vendor/autoload.php';
require_once '../pricer.php';
class PricerTest extends TestCase
{
#[Test]
public function shouldReturnTheFirstPositiveAnswer(): void
{
$firstFetcher = $this->createMock(SecurityPriceFetcher::class );
$secondFetcher = $this->createMock(SecurityPriceFetcher::class );
$security = new MutualFund("123456", "Mutual fund 1");
$date = new DateTimeImmutable();
$expectedPrice = new SecurityPrice($security, $date, 1);
$firstFetcher
->method("fetch")
->willReturn($expectedPrice);
$secondFetcher
->expects($this->never())
->method("fetch");
$actualPrice = getPriceFor(
$security,
$date,
[
$firstFetcher,
$secondFetcher,
]
);
$this->assertEquals($expectedPrice, $actualPrice);
}
}
Allow me to illustrate it with another example around the same domain. Let’s say that we want to keep a log of every API call we make. We might be tempted to go back to our getPriceFor function and simply add a little line like:
<?php
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;
function getPriceFor(Security $security, DateTimeInterface $date, array $fetchers): ?SecurityPrice
{
global $logger;
foreach ($fetchers as $fetcher) {
$logger->log("Trying ".get_class($fetcher));
$price = $fetcher->fetch($security, $date);
if ($price !== null) {
return $price;
}
}
return null;
}
Looks innocent, doesn’t it? It’s just a simple line, what harm could it do? Probably nothing, but we’re changing a perfectly working piece of code for no good reason.
To make my next point more explicit, let’s assume we’re only interested in logging calls to the first and second APIs, but not the third. Suddenly, things got weird. Are you going to add an if on top of the call to the logger? That doesn’t seem like a good idea. Then again, why should you have to modify code that is performing its duty? A better approach is to put together a small Decorator around the classes that deal with the APIs you’re interested in logging.
It all starts with this definition:
<?php
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPriceFetcher;
class LoggedPriceFetcher implements SecurityPriceFetcher {
private SecurityPriceFetcher $wrapped;
private Logger $logger;
public function __construct(SecurityPriceFetcher $wrapped, Logger $logger) {
$this->wrapped = $wrapped;
}
public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
$this->logger->log("Trying ".__CLASS__);
return $this->wrapped->fetch($security, $date);
}
}
We simply create a wrapper around the actual worker class and add the new functionality around the original one. In this case, we’re logging before making the call, but in the same fashion, we might have done it afterwards.
Now it’s up to the caller to use the regular SecurityPriceFetcher or the decorated one.
$logger = new Logger();
echo getPriceFor(
new Stock("I12321312W", "GoogStock"),
new DateTimeImmutable()->sub(new DateInterval("P1D")),
[
new FirstAPIPriceFetcher(),
new LoggedPriceFetcher(new SecondAPIPriceFetcher(), $logger),
new ThirdAPIPriceFetcher(),
]
)->value;
The most important detail here is that, for this to work, the decorator must implement the same interface as the decorated class. That is the “trick” to have the client code (getPriceFor in our case) completely ignorant of the fact that it’s talking to an augmented version of the object it expects.
Perhaps logging doesn’t look like such a big deal to you. Let me try to convince you with a more nuanced example. Let’s say that some APIs measure their prices in Euros while others do it in USD, and your application uses Euros all around. The same principle applies. You could implement this conversion logic in every PriceFetcher or even at the getPriceFor level, but that would be a waste, to say the least, and a big problem if things get out of hand. Think about how you’ll keep track of different exchange rates if they’re scattered all over the place.
Now that you know how your Decorators can save the day (and let’s admit it, make you look cool), why not use one of those bad boys? The gist is pretty similar. We start with:
<?php
namespace Mauro\Strategy;
use DateTimeInterface;
class ConvertToEuroPriceFetcher implements SecurityPriceFetcher
{
private SecurityPriceFetcher $wrapped;
private USDToEURConverter $converter;
public function __construct(SecurityPriceFetcher $wrapped, USDToEURConverter $converter)
{
$this->wrapped = $wrapped;
$this->converter = $converter;
}
public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
return $this
->converter
->convert($this->wrapped->fetch($security, $date));
}
}
And then we can use it as we see fit:
$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
new Stock("I12321312W", "GoogStock"),
new DateTimeImmutable()->sub(new DateInterval("P1D")),
[
new FirstAPIPriceFetcher(),
new LoggedPriceFetcher(
new SecondAPIPriceFetcher(),
$logger
),
new ConvertToEuroPriceFetcher(
new ThirdAPIPriceFetcher(),
$converter
),
]
)->value;
A really cool thing about Decorators is that you can combine them however you want. For instance, you may want to log the calls to the API that need currency conversion. You don’t need to go too far to achieve such behavior. Since the Decorator exposes the same interface as the worker, there’s no reason why you can’t use a decorated object as the input to another decorator:
$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
new Stock("I12321312W", "GoogStock"),
new DateTimeImmutable()->sub(new DateInterval("P1D")),
[
new FirstAPIPriceFetcher(),
new LoggedPriceFetcher(
new SecondAPIPriceFetcher(),
$logger
),
new ConvertToEuroPriceFetcher(
new LoggedPriceFetcher(
new ThirdAPIPriceFetcher(),
$logger
),
$converter
),
]
)->value;
As you can see, in the case of the third API, I am using both decorators. With the second API, I’m only using one of them. That’s the beauty of this pattern: you can use any conjunction you need to achieve your goals.
Now, there’s a subtle issue I want to clarify. In this example, the decoration order doesn’t change anything. If I wrote:
$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
new Stock("I12321312W", "GoogStock"),
new DateTimeImmutable()->sub(new DateInterval("P1D")),
[
new FirstAPIPriceFetcher(),
new LoggedPriceFetcher(
new SecondAPIPriceFetcher(),
$logger
),
new ConvertToEuroPriceFetcher(
new LoggedPriceFetcher(
new ThirdAPIPriceFetcher(),
$logger
),
$converter
),
]
)->value;
The end result would look exactly the same as the former example, but that’s a mere coincidence. In this particular case, the decorations deal with completely different aspects of the problem, so there’s no interference. In many other scenarios where this is not the case, you want to be very mindful of the order in which your decorators are applied.
Consider a hypothetical CachedPriceFetcher Decorator. Should you log before or after checking the cache? That depends entirely on what you’re trying to observe. The ordering becomes a meaningful design decision rather than an afterthought.
Another point I’d like to explore in this article is the use of Traits instead of Decorators. After all, they seem to offer a pretty similar advantage, don’t they? With Traits, you can have your objects implement extra functionality without re-coding it over and over. So, should you prefer them to old-fashioned Decorators? I’m afraid the answer is most likely “No.”
Here’s why. Traits are, at their core, a form of horizontal inheritance. When a class uses a Trait, the link is static—sealed at compile time. You can’t choose at runtime to give an instance of FirstAPIPriceFetcher the logging trait but not the conversion trait, and another instance the other way around.
What I mean by this is that, unlike Decorators, you can’t dynamically combine them however you might need. Once a class uses a Trait, that’s it; their link is sealed for good. You can always resort to some obscure reflection tricks to break it, but if you have to go that way, you should be having second thoughts already.
Let me show you an example to make the point clearer. Say you implement the logging mechanism as a trait:
<?php
namespace Mauro\Strategy;
use Logger;
trait LoggableFetcherTrait
{
private Logger $logger;
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
private function logAttempt(string $className): void
{
if (isset($this->logger)) {
$this->logger->log("Trying " . $className);
}
}
}
That would imply changing the Strategies to something like:
<?php
use Mauro\Strategy\LoggableFetcherTrait;
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;
use Mauro\Strategy\SecurityPriceFetcher;
class FirstAPIPriceFetcher implements SecurityPriceFetcher
{
use LoggableFetcherTrait;
public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
{
$this->logAttempt(__CLASS__);
// Do the magic
return $price;
}
}
This takes away the whole point of decorators, doesn’t it? Now you need to explicitly call the log method. More so, when the time comes to combine these kinds of traversal features, the headaches grow exponentially as your classes need to go out of their way to keep track of responsibilities they didn’t have before.
Also, their all-or-nothing nature is the definite argument against Traits in this scenario. When you use a Trait, you are saying that all instances of the class will exhibit a specific behavior. With a Decorator, you can decide that on a case-by-case basis, giving you way more flexibility.
Now, I don’t want you to end up with the idea that Traits are somehow evil or that I have something personal against them. As with any other tool, they have their use cases, such as timestamp management, soft-deletes, or serialisation helpers. It’s just that they are not a replacement for Decorators.
As for the testing side, the same principles I discussed for the Strategy case apply here. Unlike the monolithic function version, there’s no need for real (expensive and unpredictable) API calls, elaborate mocks, or reflection gymnastics. Each test stays focused and clean.
Though I didn’t mention it explicitly until now, it’s worth noting that, by leveraging these patterns, you’re complying with both Single Responsibility and Open/Close principles (a very important part of SOLID).
Both the Strategy and the Decorator have a very clear function, one that can be defined independently of the system surrounding them and, most importantly, changed without affecting it.
Should the protocol change for any of the APIs, the update would be circumscribed to a single class, which, once properly tested, can safely replace the pre-existing implementation.
The same is true for the Decorators. Since there is such a clean separation of concerns, moving to a different logging mechanism doesn’t generate any impact on any part of the application whatsoever.
At this point, it should be clear that new Strategies and Decorators can be added at any time and without significant effort, which effectively makes the application easily extensible to adapt to the constantly evolving business environment. Of course, this is by no means a coincidence. The patterns were designed with these goals in mind.
As with any other tool in your box, there are times when the best course of action is to leave them out of sight, and, while the patterns I’ve shown you through this article are really valuable, there are times when they take more than they provide.
In general, I’d recommend you stay away from Strategy when you only have one implementation and no foreseeable need for others. I want to stress the last part of that sentence: don’t let yourself get trapped in “but what if…?” thoughts. You’ll fix problems when they’re actually there; anything before that is just speculation.
Also, skip the Decorator when the additional behavior is always required. If you never create an undecorated version, there’s no point in the wrapper.
Here’s what you learned in this article:
Here’s a little cheat sheet in case you’re in doubt about which option to use:

The next time you are tempted to add a boolean parameter like $withLogging or $convertToEuro to your function, stop for a second and ask yourself, ”Am I choosing a path, or am I adding a layer?” Either way, chances are a Strategy or a Decorator will be a better option in the long run. And you already know how to make the choice between them.
Your future self—the one who will have to maintain this code six months from now when the APIs change and the business rules double—will thank you for choosing the elegance of composition over the immediate convenience of an if statement.
The post Strategy vs Decorator in PHP: Choosing the Right Design Pattern appeared first on International PHP Conference.
]]>The post The First AI Coding Agent Built Entirely in PHP appeared first on International PHP Conference.
]]>All news about PHP and web development
That’s the gap Neuron AI was built to close. And now, with Neuron v3 introducing a workflow-first architecture, I wanted to prove the point in the most direct way possible: build something that the ecosystem assumes can only be done in another language. That’s how Maestro was born—the first coding agent built entirely in PHP.

Fig. 1: Maestro CLI agent

Fig. 2: Maestro help command

Fig. 3: Maestro too approval

Fig. 4: Maestro file edit
Neuron is a PHP framework for developing agentic applications. By handling the heavy lifting of orchestration, data loading, and debugging, Neuron clears the path for you to focus on the creative soul of your project. From the first line of code to a fully orchestrated multi-agent system, you have the freedom to build AI entities that think and act exactly how you envision them.
Neuron provides tools for the entire agentic application development lifecycle, from LLM interfaces, to data loading, to multi-agent orchestration, to monitoring and debugging. In addition, it provides tutorials and other educational content to help you get started using AI Agents in your projects.
Neuron’s architecture prioritizes the fundamentals that experienced engineers expect from production-grade software.

Fig. 5: Neuron architecture
The framework leverages PHP 8’s mature type system throughout its codebase, with every method signature, property, and return value explicitly typed. The entire framework passes PHPStan 100% type coverage.
The strongly-typed approach means your IDE can provide accurate autocompletion for agent configurations, tool parameters, and response handling. Method signatures include detailed PHPDoc annotations that provide context beyond type hints when needed, explaining parameter expectations and return value structures.
This foundation allows faster debugging cycles, easy integration patterns with frameworks like Symfony or Laravel. We assume you’re building systems that need to be maintained, extended, and understood by teams rather than individual experiments.
Neuron uses standard PSR interfaces where appropriate and maintains minimal external dependencies, avoiding conflicts across different PHP environments and framework versions. This design choice prevents the common problem where introducing a new library increases the risks of getting stuck due to incompatible versions of dependencies.
For teams working across multiple projects, this approach provides consistency. The same Neuron patterns and implementations work regardless of whether you’re building a new microservice in pure PHP, extending a WordPress site, or adding features to an enterprise Symfony and Laravel application. Knowledge transfer between projects becomes seamless, and developers can leverage their Neuron expertise across their entire PHP portfolio.
These design principles create a unified ecosystem for AI development across all PHP communities. Rather than fragmenting innovation across framework-specific solutions, Neuron enables collaboration between Laravel developers, Symfony contributors, WordPress plugin authors, and custom framework maintainers. When improvements are made to Neuron’s core capabilities, they benefit every PHP developer.
Neuron’s universal approach attracts contributors from across the PHP ecosystem, leading to more robust implementations, broader testing across different environments, and faster development of new features. This collaborative approach also means better support for newcomers, as experienced developers from various PHP backgrounds can provide guidance and assistance.
Before looking at the code, it’s worth being precise about what a coding agent is, because the term gets stretched a lot. Maestro isn’t a code completion tool. It’s an autonomous agent that runs in your terminal, reads your project files, reasons about your codebase, and proposes changes. It operates in a loop: you give it a task, it decides which tools to call (read a file, search for patterns, write changes), executes them in sequence, and reports back. The key word is proposes—before touching your filesystem, it asks for your approval.
That last part is not simply a nice to have feature. Any agent with write access to your codebase that doesn’t pause for confirmation is a liability. The tool approval mechanism in Maestro is one of the things I’m most satisfied with, and it maps directly to a feature that Neuron v3 introduced as a first-class concept: human-in-the-loop workflow interruption.
The repository structure reflects a clear separation of concerns. The entry point is bin/maestro, which bootstraps a Symfony Console command. From there, everything fans out cleanly:
bin/maestro (Symfony Console Application)
└─ MaestroCommand (main command)
├─ Settings (.maestro/settings.json)
├─ EventBus\EventDispatcher (PSR-14 compatible)
├─ CliOutputListener (subscribes to events)
└─ AgentOrchestrator (drives chat loop)
└─ CodingAgent (extends NeuronAI Agent)
├─ ProviderFactory → AIProvider
├─ FileSystemToolkit (read-only FS tools)
└─ McpConnector[] (optional MCP servers)
The CodingAgent class extends Neuron’s Agent base and adds a tool approval middleware. This is the piece that intercepts execution before any filesystem write, fires a ToolApprovalRequestedEvent, and waits. The AgentOrchestrator catches the workflow interrupt thrown by the middleware, presents the approval prompt to the user via the CLI, and resumes or aborts execution based on the response.
This pattern—interrupt, present, resume—would have been painful to implement without a workflow-oriented framework underneath. With Neuron v3, it’s the natural way to build it.
The Maestro CLI implements an elegant inline command system that allows users to execute special commands directly from the interactive chat interface without exiting the main loop. You can type “slash commands” (e.g., /help, /init) that are handled by a plugin-like registry system.
The architecture relies on three core components: a clean InlineCommand interface that defines the contract for all commands, a central registry that manages command registration and lookup while preventing duplicate names, and an adapter class that enables wrapping existing Symfony Console commands as inline commands without rewriting their logic.
What makes this system particularly powerful is its extensibility through the adapter pattern. Rather than duplicating code between standalone console commands and their inline counterparts, the InlineCommandAdapter class can wrap any existing Symfony command, automatically extracting the command name and description and handling the conversion between inline argument strings and Symfony’s input format. This design choice means commands like /init can reuse the full logic of the InitCommand class, including its interactive prompts and validation, while presenting a simplified interface within the chat session.
The registry pattern naturally supports command discovery through the built-in /help command, which dynamically lists all registered commands with their descriptions, helping the user understand the available CLI capabilities. Adding a new inline command is as simple as implementing the interface and registering it in the constructor.
class MaestroCommand extends Command
{
protected InlineCommandRegistry $registry;
public function __construct(?string $name = null, ?callable $code = null)
{
parent::__construct($name, $code);
// Initialize inline commands
$this->registry = new InlineCommandRegistry();
$this->registry->register(new InitInlineCommand());
$this->registry->register(new HelpInlineCommand($this->registry));
}
...
}
Install as a global composer tool:
composer global require neuron-core/maestro
Make sure Composer’s global bin directory is in your system
PATH:
export PATH="$HOME/.config/composer/vendor/bin:$PATH"
Configuration lives in .maestro/settings.json at the root of your project. Run the init command to start the interactive guide:
cd /pth/to-project
maestro init
At minimum, you need a provider and an API key:
{
"default": "anthropic",
"providers": {
"anthropic": {
"api_key": "sk-ant-your-key-here",
"model": "claude-sonnet-4-20250514"
}
}
}
Maestro supports Anthropic, OpenAI, Gemini, Cohere, Mistral, Ollama, Grok, and Deepseek out of the box—all routed through a ProviderFactory that maps the default field to the corresponding Neuron AI provider instance. If you want to run everything locally without sending data to an external API, point it at an Ollama instance:
{
"default": "ollama",
"provider": {
"ollama": {
"base_url": "http://localhost:11434",
"model": "llama2"
}
}
}
By default Maestro tries to load the Agents.md file from the project directory. Alternatively, you can point Maestro at a different markdown file in your repo that describes your project’s architecture, coding standards, and any conventions the agent should follow. You can configure the context_file property in the settings file with the path to the file.
{
"default": "ollama",
"providers": { ... },
"context_file": "CLAUDE.md"
}
The agent appends the content of that file to its system instructions before the conversation starts. This is a simple mechanism, but it makes a real difference in practice. An agent that knows your project uses PSR-12, that controllers shouldn’t contain business logic, and that you prefer dependency injection over service locators will produce more relevant suggestions from the first message.
All news about PHP and web development
When the agent wants to modify a file, execution doesn’t just proceed. It stops, and you see something like this:
The agent wants to write changes to src/Service/UserService.php
[1] Allow once
[2] Allow for session
[3] Always allow
[4] Deny
“Allow for session” is the option I use most in practice. It means I approve write operations on a given file type or tool once per session, without having to confirm each individual change. “Always allow” persists the preference to .maestro/settings.json under an allowed_tools key, so future sessions skip the prompt entirely for that operation.
This granularity matters. You probably want to approve the first few changes in an unfamiliar session to build confidence, then let the agent run more freely once it’s demonstrated it understands what you’re asking.
This is one of the most interesting features provided by the Neuron AI framework, thanks to the Workflow architecture. Neuron Workflow supports execution interruption, so you can create a fully customizable human-in-the-loop experience. The agent will stop its execution, waiting to be resumed exactly from where it left off. Learn more on the official documentation.
Maestro uses a lightweight PSR-14-compatible event dispatcher with three events: AgentThinkingEvent (fires before each AI call), AgentResponseEvent (fires when the model returns), and ToolApprovalRequestedEvent (fires when a tool needs approval). The CliOutputListener subscribes to these and handles all terminal rendering.
This design keeps the agent logic clean. The CodingAgent doesn’t know anything about how output is displayed—it just fires events. If you wanted to build a web interface on top of the same agent, you’d swap out the listener and leave everything else untouched.
For teams that want to extend the agent’s capabilities beyond filesystem operations, Maestro supports Model Context Protocol servers in the configuration:
{
"mcp_servers": {
"inspector": {
"url": "https://app.inspector.dev/mcp?app=<APP_ID>",
"args": "INSPECTOR_API_TOKEN"
}
}
}
Each entry in mcp_servers spins up a subprocess and connects it to the agent as an additional tool source. The agent can then check the application monitoring data on Inspector , search the web, or access any MCP-compatible service alongside its native filesystem tools.
Maestro is a working proof that the patterns the rest of the industry has been building in Python and TypeScript are fully expressible in PHP now. The workflow architecture that makes tool approval possible, the event-driven rendering pipeline, the multi-provider abstraction, the MCP integration, none of this required stepping outside the PHP ecosystem.
The framework doing the heavy lifting here is Neuron AI, specifically the workflow architecture introduced in v3. Without the ability to interrupt execution mid-agent-loop and resume it based on user input, the tool approval system would require significantly more scaffolding to build and maintain.
I’m really looking forward to hearing your feedback, experiments, and ideas on how to develop this new chapter of AI in the PHP space.
If you want to explore the code, the repository is at github.com/neuron-core/maestro. The Neuron AI documentation lives at docs.neuron-ai.dev. Questions, issues, and pull requests are open.
The post The First AI Coding Agent Built Entirely in PHP appeared first on International PHP Conference.
]]>The post Better HTML Parsing in PHP: Modern Techniques and Tools appeared first on International PHP Conference.
]]>
The HTML is parsed into a DOM (Document Object Model) object. When you use your browser’s developer tools to inspect an element on a webpage, you are relying on the DOM. We can also turn a DOM object back into an HTML string if we need to save it somewhere.

For many developers, parsing HTML has long been a source of frustration. While PHP’s DOMDocument class has allowed us to do this, its reliance on the libxml2 library meant it could not handle the kind of HTML that browsers today have to deal with. It tripped up on certain inline JavaScript, leaking the code into other parts of the resulting DOM, and often required unreliable hacks to produce a decent result.
Note: There has been an open issue to add HTML5 support in libxml2 for a while. There also appears to have been some progress in the last year. At the same time, however, the maintainer of the library, Nick Wellnhofer, has announced he is stepping down. So at the time of writing, it’s unclear what the future holds.
With the release of PHP 8.4 in November 2024, that era is finally over.
All news about PHP and web development
PHP 8.4 introduces a massive overhaul to the DOM extension, featuring a new, standards-compliant HTML5 parser [2], native CSS selector support, and a modernized set of DOM classes. These additions make PHP a viable, high-performance choice for web scraping, content extraction, and HTML transformation tasks that previously required slower third-party libraries based on older specs.
Note: From this point on, we will simply use “HTML” rather than “HTML5.” The current standard is called the “HTML Living Standard,” maintained by WHATWG.
I’m slowly updating the PHP Readability library, used for article extraction, to use the new DOM API in PHP. In this article, we’ll explore what’s new, and walk through practical examples of how to migrate your own code.
The secret sauce behind PHP 8.4’s parsing capabilities is Lexbor, a high-performance HTML parser written in C by Alexander Borisov. Unlike libxml, Lexbor is based on the WHATWG HTML Living Standard. This means it parses HTML more like a modern web browser does – handling unclosed tags and quirky markup.
Because Lexbor is a C library, it is incredibly fast. It eliminates the overhead of userland parsers (like the popular html5-php library) and often outperforms the libxml parser while providing significantly better accuracy. It is included in the DOM extension by default, requiring no extra configuration or external dependencies.
The integration of Lexbor into PHP, along with the other DOM changes described in this article, came about thanks to Niels Dossche. Niels is a PHP core contributor and researcher at Ghent University.
For backward compatibility, he has ensured that the changes do not affect existing code, by providing the new DOM classes under the Dom namespace.

To really appreciate the upgrade, let’s first look at the old way. The native PHP way has been to rely on the DOMDocument class, which uses libxml under the hood. While libxml is excellent for XML, it predates the current HTML Living Standard and has struggled with modern markup for a long time.
Consider the following HTML document. It contains two paragraphs and a script element in between them.
<!DOCTYPE html>
<title>Valid HTML Document</title>
<p>Paragraph 1</p>
<script>console.log("</html>Console log text");</script>
<p>Paragraph 2</p>
This is valid HTML. A browser knows that </ html> is inside a string in a script element and should be treated as text. However, DOMDocument gets confused.
Note: Many opening and closing tags can be omitted, e.g. < html>, < head>, < body>. The parser will infer them automatically. I want to stress that all the HTML I’m presenting in this article is valid, conforming to the current HTML standard. While the HTML parser spec goes into details about how to handle invalid, non-conforming HTML, we’re setting a lower bar here when comparing parsing results, using HTML you’ll encounter in the wild.
$dom = new DOMDocument();
$dom->loadHTML($html);
$paragraphs = $dom->getElementsByTagName('p');
echo "Found {$paragraphs->length} paragraphs.";
// Output: Found 3 paragraphs.
Why 3 paragraphs, and not 2? Because the DOMDocument sees the </ html> inside the script, assumes the document has ended, and then treats the remaining text (Console log text”);) and the second paragraph as new content outside the body, mangling the structure entirely. If you serialize this back to HTML, you get a broken mess:
<html>
<body>
<p>Paragraph 1</p>
<script>console.log("</script>
</body>
</html>
<html>
<p>Console log text");</p>
<p>Paragraph 2</p>
</html>
![Two-column table mapping CSS selectors to XPath 1.0 expressions, with examples such as div.content, article#main, [src*="avatar"], article p, and article > p, plus two emoji-marked rows showing more awkward XPath equivalents for matching text and links.](https://phpconference.com/wp-content/uploads/2026/04/Picture4.png)
So what have developers done about this? Historically, there have been two main approaches:
With the new parser, neither of these should be needed now.
Note: Tidy re-writes the HTML in a way that older parsers can sometimes parse better. But not always. I’ve encountered HTML which either Tidy itself struggles with, or in which Tidy’s output doesn’t produce better results when passed to PHP’s DOMDocument.
With the release of PHP 8.4, PHP introduces the new Dom\HTMLDocument class. When you parse HTML using this class, you are using Lexbor, PHP’s new HTML parser.
Here is how we parse the same document with Lexbor, using the new class:
$dom = Dom\HTMLDocument::createFromString($html);
$paragraphs = $dom->getElementsByTagName('p');
echo "Found {$paragraphs->length} paragraphs.";
// Output: Found 2 paragraphs.
The new parser correctly identifies the context of the script tag and preserves the document structure.

Comparing PHP’s new parser with the html5-php library, I found the native PHP implementation is approximately 3.6x faster on average for typical news and blog pages. For larger, more complex documents, users should find it even faster.
More importantly, it adheres to a more recent HTML standard. HTML today is a “Living Standard” maintained by the WHATWG, meaning it has no version numbers and changes over time. Both libxml and html5-php are based on older standards. Lexbor, PHP’s new parser, is based on the more recent WHATWG standard, so is closer to modern browser parsing.
To support the new features without breaking decades of existing code, PHP 8.4 introduces a new set of DOM classes under the DOM namespace. These live alongside the existing global classes (like DOMDocument), allowing both APIs to coexist in the same application.
Here is how the key classes map to the new namespace:
Why create new classes instead of fixing the old ones? Niels found that attempts to fix bugs in the old DOM classes caused too many issues because many of us have had to rely on the incorrect behavior. By creating a fresh namespace, the new classes can adhere strictly to the spec while the old classes remain untouched for legacy code.
Thankfully, migration doesn’t have to be all-or-nothing. You can mix both APIs in the same codebase. And if you need to, you can import legacy DOMNode objects with the importLegacyNode method:
$oldDom = new DOMDocument();
$oldDom->loadHTML('<p>Old node</p>');
$oldElement = $oldDom->getElementsByTagName('p')->item(0);
echo "Old element class: " . $oldElement::class . PHP_EOL;
// Output: Old element class: DOMElement
$newDom = Dom\HTMLDocument::createFromString('<!DOCTYPE html>');
$newElement = $newDom->importLegacyNode($oldElement, deep: true);
echo "New element class: " . $newElement::class . PHP_EOL;
// Output: New element class: Dom\Element
$newDom->body->append($newElement);
// Serialise to HTML
echo $newDom->body->innerHTML;
// Output: <p>Old node</p>
The new API introduces several quality-of-life improvements that reduce boilerplate code.
All news about PHP and web development
You no longer need to traverse the tree to find the < body > or < head > tags. They are now exposed as first-class properties on the document object:
$html = '<!DOCTYPE html> <title>Old title</title> <h1>Hello</h1>'; $dom = Dom\HTMLDocument::createFromString($html); // Access convenience elements directly echo $dom->head::class . PHP_EOL; // Dom\HTMLElement echo $dom->body::class . PHP_EOL; // Dom\HTMLElement // Read or write the title directly echo $dom->title . PHP_EOL; // Output: Old title $dom->title = "New Title"; echo $dom->head->innerHTML; // Output: <title>New Title</title>
What about getting the HTML content of an element? We now have native innerHTML support.
It works just like JavaScript:
$div = $dom->querySelector('div');
// Read content
echo $div->innerHTML;
// Write content (automatically parses the string into nodes)
$div->innerHTML = '<p>Replaced content</p>';
Note: While_ innerHTML is supported, _outerHTML is not yet available in this release.
Perhaps the most exciting feature for web scraping is native support for CSS selectors. You can finally say goodbye to getElementsByTagName and the complexity of DOMXPath.
The new classes implement querySelector and querySelectorAll, behaving identically to their JavaScript counterparts:
$dom = Dom\HTMLDocument::createFromString($html);
// Find the first matching element
$article = $dom->querySelector('article.main');
// Find all matching elements (returns a NodeList)
$links = $dom->querySelectorAll('ul.nav > li > a');
You aren’t limited to basic class or ID selectors. You have access to modern, complex CSS selectors:
Multiple Element Types: Select headers and paragraphs in one go:
$elements = $dom->querySelectorAll('h1, h2, h3, p');
Combinators (:is, :where): Simplify complex grouping:
// Select paragraphs and main headings that are direct children of article
$elements = $dom->querySelectorAll('article > :is(p, h1, h2)');
// Same as
// $elements = $dom->querySelectorAll('article > p, article > h1, article > h2');
State Selectors (:empty, :not):
// Find all paragraphs that are NOT empty
$elements = $dom->querySelectorAll('p:not(:empty)');
Relational Pseudo-class (:has): Get h1 headings that are followed immediately by an h2 heading:
$headings = $dom->querySelectorAll('h1:has(+ h2)');
Get all paragraphs in an article that have at least one link inside them:
$paragraphsWithLinks = $dom->querySelectorAll('article p:has(a)');
Attribute Selectors: Target specific attribute values, including partial matches (note the ‘i’ to signal case-insensitive matching):
// Find secure external links
$secureLinks = $dom->querySelectorAll('a[href^="https://" i]:not([href*="example.com" i])');
Note: One missing feature is the :scope pseudo-class, which can be used to refer to the current element when there’s a need to use a combinator. Using it currently throws a DOMException. $article->querySelectorAll(‘:scope > p’) This is a known limitation in Lexbor, and it is being worked on.
While CSS selectors are an excellent new addition, XPath remains available. I recommend using CSS selectors whenever you can, as they’re usually easier and more concise to write.
In the past, people would turn to XPath because CSS selectors were not as powerful as they are today, and they were not available in PHP natively. Those who wanted to use CSS selectors in PHP had to rely on libraries that converted CSS to XPath under the hood, such as Symfony’s CssSelector component.
Nonetheless, XPath can still be used if you need more complex logic in your selectors or if you’re migrating code that already relies on XPath.
![Two-column table mapping CSS selectors to XPath 1.0 expressions, with examples such as div.content, article#main, [src*="avatar"], article p, and article > p, plus two emoji-marked rows showing more awkward XPath equivalents for matching text and links.](https://phpconference.com/wp-content/uploads/2026/04/Picture6.png)
Common CSS/XPath selectors
It’s important to note that if you’ve previously used XPath with HTML parsed with PHP’s DOMDocument, switching to Dom\HTMLDocument will require that you pay attention to namespaces.
The new parser assigns namespaces to HTML, SVG and MathML elements, in line with the HTML standard. This means XPath queries that worked before may return empty results. Consider this HTML with an embedded SVG:
<article> <svg width="200" height="100"> <text x="100" y="50">Hello SVG</text> </svg> </article>
With the old DOMDocument, a simple XPath query works without any namespace handling:
$dom = new DOMDocument();
$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$texts = $xpath->query('//article//svg');
// Works: returns the <svg> element
However, with Dom\HTMLDocument, the same query returns nothing because the article and SVG elements are now placed in the HTML and SVG namespaces:
$dom = Dom\HTMLDocument::createFromString($html);
$xpath = new Dom\XPath($dom);
$texts = $xpath->query('//article//svg');
// Returns empty! The elements are in a namespace.
To fix this, you must register the namespace and use a prefix in your XPath:
$dom = Dom\HTMLDocument::createFromString($html);
$xpath = new Dom\XPath($dom);
$xpath->registerNamespace('h', 'http://www.w3.org/1999/xhtml');
$xpath->registerNamespace('s', 'http://www.w3.org/2000/svg');
$texts = $xpath->query('//h:article//s:svg');
// Works: returns the <svg> element
However, this does have a downsider: if your source HTML uses < template > elements, the contents of those elements will no longer be hidden when working with the DOM.
Serialization is turning the DOM object you’ve been working with into an HTML string. If you need to save the results as an HTML file or store it in a database, you’ll want to serialize.
// Save the entire document echo $dom->saveHtml(); // Save a specific node (and its children) echo $dom->saveHtml($dom->body);
Now that we understand the API, let’s put it to work in some real-world scenarios.
Here is an example of extracting quotes and their authors into an array:
use Symfony\Component\HttpClient\HttpClient;
// Fetching the content
$client = HttpClient::create();
$response = $client->request('GET', 'https://quotes.toscrape.com/');
$html = $response->getContent();
// Parsing HTML with Lexbor
$dom = Dom\HTMLDocument::createFromString($html);
// Extract quotes using CSS selectors
$quotes = [];
foreach ($dom->querySelectorAll('.quote') as $element) {
$quote = $element->querySelector('.text')->textContent;
$author = $element->querySelector('.author')->textContent;
$authorUrl = $element->querySelector('a[href ^= "/author/"]')->getAttribute('href');
$quotes[] = [
'quote' => mb_trim($quote),
'author' => mb_trim($author),
'authorUrl' => $authorUrl
];
}
print_r($quotes);
When working with real-world web pages, you will likely encounter HTML that contains shell elements that are then filled with content after JavaScript has been executed in your browser.
If the content you’re after requires JavaScript rendering, you will want to use a headless browser. There are services you can use for this, or if you’re testing locally, you can use Chrome’s –dump-dom flag:
chrome --headless --dump-dom https://quotes.toscrape.com/js/
You can capture the output in PHP with the following:
$url = 'https://quotes.toscrape.com/js/'; $command = 'chrome --headless --dump-dom ' . escapeshellarg($url); $html = shell_exec($command);
A common task when working with HTML is to remove the bloat that is often interleaved with the content that you want to extract. This can be ads, related links, social media share buttons, and so on.
With CSS selectors, it’s easy to target all these in one comma-separated selector list.
$dom = Dom\HTMLDocument::createFromString($html);
// Remove clutter (scripts, styles, navs, footers)
$selector = 'script, style, nav, footer, aside, .ad-banner, .social-share';
foreach ($dom->querySelectorAll($selector) as $clutter) {
$clutter->remove();
}
If you’re working with web articles (e.g., news stories, blog posts), I maintain the PHP port of Readability.js, which can be useful to isolate the content HTML automatically before you parse and work on it further.
use fivefilters\Readability\Readability; use fivefilters\Readability\Configuration; // Article URL $url = 'https://www.medialens.org/2020/cogitation-meditation-in-an-age-of-cataclysms/'; // for simplicity we'll use file_get_contents() here $html = file_get_contents($url); // Configure Readability $configuration = new Configuration([ 'fixRelativeURLs' => true, 'originalURL' => $url, ]); // Detect and extract article body $readability = new Readability($configuration); $readability->parse($html); $contentHtml = $readability->getContent(); $dom = Dom\HTMLDocument::createFromString($contentHtml);
When working with HTML you have not produced yourself (e.g., HTML you have fetched, or user-submitted content), you are handling untrusted HTML. Before outputting it for display, you should sanitize it to prevent XSS attacks. Symfony’s HTML Sanitizer component is designed for this.
use Symfony\Component\HtmlSanitizer\HtmlSanitizer; use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; $config = new HtmlSanitizerConfig()->allowSafeElements()->allowRelativeLinks(); $sanitizer = new HtmlSanitizer($config); $dirty = '<a href="/page" onclick="alert(\'XSS\')">Click</a>'; echo $sanitizer->sanitize($dirty); // Output: <a href="/page">Click</a>
The sanitizer automatically strips dangerous attributes like onclick while preserving allowed elements and attributes.
All news about PHP and web development
Migrating to the new API is generally straightforward, but there are a few key differences to be aware of.
If you are currently using the HTML5-PHP library, you can likely remove it entirely.
Old way (HTML5-PHP):
$html5 = new Masterminds\HTML5(); $dom = $html5->loadHTML($html);
New way (Native):
$dom = Dom\HTMLDocument::createFromString($html);
Not only is the code cleaner, but you will also see an immediate performance improvement. Note, however, that HTML5-PHP returns the legacy DOMDocument object after parsing, while the new code above returns Dom\HTMLDocument. So you might notice some differences in the API.
PHP 8.5 introduces an updated URL parser. When working with HTML, we often also work with URLs. If you’ve used the parse_url function in the past, I recommend switching to the new URI extension.
The introduction of Dom\HTMLDocument in PHP 8.4 is a major update to PHP’s HTML capabilities. It transforms PHP from a language that could do HTML parsing (with enough caveats and libraries) into a language that really excels at it.
Whether you are building a simple scraper or a complex content transformation engine, there has never been a better time to do it in PHP.
Special thanks to Niels Dossche for his incredible work on this extension and Alexander Borisov for the Lexbor project.
The post Better HTML Parsing in PHP: Modern Techniques and Tools appeared first on International PHP Conference.
]]>The post Make Better Architecture Decisions appeared first on International PHP Conference.
]]>Monoliths commonly evolve as we build an application bottom-up – adding controllers, views, and new database entities. We tend to couple the various parts of our system together, seeing system interactions as mirroring the related nature of the business or organisation structures that the application supports (our domain). This knowledge is discovered gradually throughout the development process, rather than being fully known from the start.
All news about PHP and web development
Many popular web application frameworks are well-suited to writing monolithic applications (e.g., Laravel, Symfony, Flask, Spring). These frameworks can, of course, be used as components in a microservice architecture as well, but reading common samples, tutorials, or viewing how their codebases are structured points more often towards a monolith.
When you combine the ease of building without a detailed plan and the abundance of well-suited frameworks, it’s no surprise that monoliths are built.
The same characteristics that apply to a codebase often also apply to the data store used for the monolith. This will usually be in the form of a relational database. Whilst they can use specific relationships in the form of foreign keys (and the benefits associated with indexing), it’s generally possible to join or filter on any field. This once again permits an application to choose its data architecture as it grows into it, not restricting where data can live or how it can be related, but allowing flexibility and changes in structure.
This “bottom-up” growth of an architecture can be an advantage because when we explore our domain, adding new features is generally just a new file away. Monoliths tend to have the simplest software lifecycle. They exist as single repositories in version control, making them simple to share or build out in feature branches. Components for common tasks such as authentication and logging are readily available. A monolith will keep most server-side logic in a single language, and can all be tested in a single test suite (usually written in the same language).
Depending on the language, a monolith may be deployed as easily as sending files to a remote server. It may require a simple packaging step (such as a zip file) or a compilation step (e.g., transpiling in TypeScript or compilation in Go). In all cases, the resulting server can be replicated by a configuration running locally, and the application is often transferable between remote servers.
At the base level, the monolith is a reliable way to build software, whether as an individual or a team. They are easy to start up, prototype, and ship. Given this potential for an easy life with a monolithic application, how do things look with microservices?
Every system has an architecture, whether we designed one or not. So if we want to make good architecture choices, we have to evaluate the options available to us, including monoliths, microservices, and other factors.
In a microservice system, we split different functionalities into distinct applications. Microservices platforms intend to reduce the likelihood of coupling between unrelated components by keeping them separated. This makes it possible for services to be in different repositories and to be built, tested, and deployed independently. Splitting the codebase into different services often aligns with different teams that may work on these services. It can even allow different services to be written in different languages/frameworks.
By aligning services with teams, microservices aim to enable more agility, as, ideally, a team can make improvements or solve bugs in their own service without the risk of affecting others. These changes should be quicker to verify, as each application would presumably be smaller than the entire stack of a monolith, with fewer tests to run and a lower risk of developing complexity.
Services will communicate via synchronous or asynchronous means. Synchronous communication tends to use some form of network request (HTTP or gRPC) and is analogous to a function call within a single system. This form of communication will be similar to how a lot of API tools between unrelated parties (so-called 3rd party APIs) work, with defined requirements (in the form of OpenAPI specifications or contract tests) in terms of requests and responses.
Asynchronous communication may use a stream (e.g., Kafka), a queue (RabbitMQ), or a notification system (AWS SNS). The chosen mechanisms for asynchronous communication all have quite different use cases – streams or notification systems allow sending a message without any intent of receipt, whilst queues tend to have the goal of executing a specific task. Asynchronous communications may mean one service can communicate without knowledge of another, but for actual work to happen in response to an event on a stream, a consumer must have insight into what messages are likely to be sent.
If microservices have different codebases, they may also have different data stores. Indeed, the increasing popularity of microservice architectures has happened around the same time as more distributed methods of data storage, like NoSQL databases, have entered the general consciousness of developers. With independent data stores, services can adapt better to their own frequencies of reads & writes and use data structures that fit their use cases better. For example, a payment system may be highly transactional and need very reliable write performance and atomicity. This is different from an emailer service that needs to perform complex segmentation queries in batches, but whose durability may matter less as long as the overall throughput is maintained.
The Wikipedia page describing Monolithic Applications makes a poor case for them. Indeed, it seems to be written by someone who doesn’t like the concept of a monolith: “A monolith is less available, less durable, less changeable, less fine-tuned, and less scalable than a well-designed distributed system.”
This use of “less than” places monoliths in a negative space. In every way, they are lesser than their microservice counterparts. However, we could look at the microservice architecture in the same way:
It’s only as available (and as durable) as its least available (and durable) synchronously coupled service. It’s only as changeable as we have an appetite to maintain versions of our internal APIs for, and it’s scalable as long as we keep the same access patterns we thought of when we started building it.
In fact, the only part about monoliths that rings true is the last part: “well-designed.”
The rise in popularity of microservices may come in part from their use at large enterprise organisations, where teams and systems reach a scale where attempting to use monolithic architectures may severely hamper progress due to the challenges of cross-team communication. This is not a present worry for development teams in the majority of organisations, but we should be careful not to follow a pattern simply because it is discussed by people working on prestigious teams or projects.
In the next section, we’ll explore a few key principles and evaluate how both architectures measure up against them. We’ll then look at how to apply these principles to improve our applications, regardless of the architectural approach we choose.
Firstly, let’s see the problems monoliths come up with when we evaluate the architecture.
In software applications, coupling refers to connections between pieces of code where the execution of one piece of code requires the presence of another.
For example, in many software-as-a-service applications, the “user” may be tightly coupled to many other concepts. Users may own specific data entries, have authentication services, manage billing, and even administer the software. In this context, the “User” entity and associated logic may be coupled to most or all parts of the codebase. Modifications to the “user” as an entity, or to the concept of what a user is/does, may impact any part of the software. The tight coupling of “user” results in brittle software; a small break in one part may result in the whole application breaking.
With all software being written in one language and one codebase, coupling is likely to occur. This is especially true if building from the bottom up. As we start to add features, we reuse parts of code that seem similar, prematurely abstracting them and later resulting in a mess of configuration options. Consider the following example:
class Utils {
/**
* Output our address components
*/
public static function addressFormatter(Address $address) :string {
return implode(', ', array_filter($address->toArray()));
}
}
But we re-use it more and more, adding parameters to meet a widening range of use cases, and progressively making the internal logic harder to reason about:
class Utils {
/**
* Output our address components
*/
public static function addressFormatter(Address $address, string $separator = ',', bool $hide_number = false, bool $show_country = false) :string {
$arr = $address->toArray();
if ($hide_number){
unset($arr['number']);
}
if ($show_country){
$arr[] = $address->getCountry();
}
return implode($separator, array_filter($arr));
}
}
Eventually, a change in one part of the application will unexpectedly break something elsewhere. While this example may be contrived, tight coupling is often a natural consequence of architectures that evolve alongside application logic without clear boundaries. Monoliths are particularly well-suited to this style of development, which is why they often end up being built this way.
Just as services can become coupled, so too can the database entities underlying a monolith. These act as a form of asynchronous coupling between parts of the system that may not appear directly connected in the code. It’s often only when multiple components rely on the same data and expect it to behave consistently that we realize seemingly unrelated parts of the codebase are, in fact, tightly linked through shared database structures.
As well as adding the normal risks of maintainability, coupling may also result in redundant data, fields on a table of 1,000 rows just used for one or two cases, because of a specific niche use case.
Another risk of keeping all data stored in a single source relates to how the infrastructure is handled. As different features require improved query performance, better transactional locks, or higher throughput, the entire platform must scale in all directions to accommodate each requirement.
All news about PHP and web development
Some of the software lifecycle advantages of monoliths may also become disadvantages over time. The singular test suite may take longer to run (even if it can be parallelised), and because the monolith may permit any part of the code to use any other part, all tests need to be run even for small changes. Not only this, but coverage becomes more important, as manual testing or QA on a change may not catch unintended changes. Depending on the form of deployment, changes may also take longer to deploy as the application increases in size. Again, the whole application must be redeployed for each change (or wholesale rolled back in the case of a defect in a single feature).
Given these limitations, monoliths are often seen as a viable approach, at least up to a certain application size. But this raises a difficult question: where exactly is the tipping point where a monolith starts to break down under its own weight? While many projects begin small, it’s common for prototypes to end up in production without the rewrite they clearly need. This reality makes it tempting to start with the architecture we hope to end with. But how well do microservices hold up when judged against the same principles?
The idea of independent services, potentially written in different languages, that are fast to build, test, and release, is appealing. It’s especially attractive when you’ve been wrestling with a highly coupled monolith that breaks in new and unpredictable ways with every small change we make.
However, just because it’s no longer easy to reuse a function in an unrelated component, our microservices often still end up coupled together through their chosen mechanisms of communication. The considerations that go into good architecture are still important, but just utilising microservices won’t be enough.
Coupling is especially easy with synchronous systems, as API calls effectively replace function calls. This can have multiple drawbacks, firstly in the surprising area of error handling and fault-tolerance.
If one service relies on another synchronously, the uptime of the overall system reduces to the lowest uptime within the system. This is not necessarily worse than a monolith, where the whole system may be heavier and thus harder to keep up, but it doesn’t suggest the uptime may be better either.
Even with a good level of uptime, networks are more error-prone than function calls within an application. Remote procedure calls can fail for various reasons and cause an error state. This means each application must be able to work out retries or determine appropriate fallback behaviour when its dependent service is unavailable.
Another challenge for synchronous systems lies in the contract between the services. If one service wishes to modify the data it would like to receive, or data it wishes to send in response, the other system may need to adjust its own interactions. Versioning of the API requests and responses will be required so that, as one system is upgraded, other systems continue to operate with it.
If both services are managed by one team, it may be possible to operate with only a single version difference between two coupled systems; upgrade system A, then upgrade system B. However, when multiple systems depend on system A, and especially if those are managed by different teams, system A may need to support multiple versions simultaneously to allow dependent systems time to update.
Asynchronous interactions are more manageable, but only if they truly are asynchronous. For example, in the context of pub-sub or stream-based architectures, it’s generally assumed that a service may publish information without any requirement that this is acknowledged or acted on. In an ideal setting, this means a service can publish whatever it wants, even multiple versions or permutations of the same message, and it is up to the consumer to choose what to act on.
These ideal settings are challenged when running up against real-life examples, where two actions may not need to happen instantly after one another, but where there is still an expectation that one follows the other.
A checkout system is a common part of many platforms, and may seem simple enough. We take customers through reserving items in a basket, entering shopping details, and making a payment. Once the payment clears, we dispatch the order and send a receipt.
However, even in a seemingly straightforward case, it’s worth asking: which parts are truly asynchronous? Each step typically depends on the successful completion of the previous one. The only arguably “optional” part might be the receipt email. But if that consistently fails, customers are likely to be unhappy. Whether this system is split into multiple services or consolidated into a single process doesn’t eliminate the challenge that the entire workflow is expected to work reliably. Putting multiple services into one process can sometimes obscure real issues, making them harder to detect and resolve.
If reducing coupling seems difficult, that’s because it genuinely is. One reason monoliths tend to grow over time is that the businesses or organisations that our software is a map of often evolve in a similarly unplanned and unstructured way. We add extra services or introduce exceptions to established patterns to seize specific opportunities or mitigate particular risks.
The same is true of all the data that our organisations create; databases become a form of asynchronous communication (and thus coupling) in their own right. We may decide to move all user authentication to an auth service with its own data store for user information. As users interact with unrelated services, we can take a copy of key information for the user and add it to custom settings pertinent to the specific application. What do we do in this case when a user wishes to modify their email address or password? Maybe that data lives just with the authentication layer, but does that then become the service that has to send them email notifications, or the newsletter?
We’ve likely all used services where navigating to a single feature involves being bounced across multiple subdomains, so many that even the browser seems dizzy by the time we get there. In reality, the idea of one user accessing multiple features tends to couple the system together anyway, as users expect a consistent, coherent experience across the entire platform.
The data-access patterns of an application built with microservices can also become a challenge. We may have designed one part of the system for high transactional load and the other for batch analytics, but what happens when we want to analyse the correlation between our newsletter signups and recent purchases, or how many people unsubscribe after a payment failure? As with monolithic codebases, there is an advantage to putting all our data in one place and working out how we want to use it later, as often we don’t know the facts up front.
Monoliths have one set of problems, while microservices come with another. Neither exists solely to fix the problems of the other, and both can be well designed (or not). One could argue that the key advantage of microservices is that when things go wrong, the issues tend to surface much earlier than they would in a monolith, where cracks can take longer to appear. However, this is only an advantage if those early warning signs lead to meaningful change, rather than simply doubling down on microservices and assuming they’re the right answer.
If we are aware of the pitfalls inherent in how a monolith grows, especially in how code and data can become coupled, then we can avoid these aspects without needing to completely change what form of architecture we are using.
By understanding the pitfalls of how a monolith grows, particularly how tightly coupled code and data can become, we can then avoid those issues without necessarily abandoning the form of architecture we are using altogether.
Putting together these principles, it’s clear that whether we’re working with an existing monolith or choosing to build a new one. We can learn from the microservice world to adopt better architecture.
Whilst code-level coupling is easier to introduce than communication coupling, it’s also easier to spot. This is especially true in smaller teams that may work on many different components in an application, but it can also apply to larger ones working on monolithic applications as well.
One way to do this is by writing our code according to its Domain, rather than its type. Many applications are written with directory structures like:
src
|- api
|- user
|- order
|- entities
|- user
|- order
|- exceptions
|- user
|- order
|_ services
Whereas we can actually organise them like:
src
|- user
|- api
|- exceptions
|- entities
|_ services
|- order
|- api
|- exceptions
|- entities
|_ services
In doing this, we start to be able to apply rules – items in the “user” directory should not be able to call public methods or create objects of classes in the “order” directory.
Some languages have features to help enforce this. For example, the popular PHP static analysis tool psalm has the @psalm-internal flag, allowing a namespace to be specified where a call is permitted:
namespace UserDomain {
class AddressService {
/** @psalm-internal UserDomain */
public static function formatPostcode(string $string):string{
return '';
}
}
}
namespace OrderDomain {
class InvoicePrinter {
public static function address(string $postcode):string{
return \UserDomain\AddressService::formatPostcode($postcode);
}
}
};
When Psalm analyzes the above code, it will output the error:
The method UserDomain\AddressService::formatPostcode is internal to UserDomain but called from OrderDomain\InvoicePrinter::address
Not all languages have a similar pattern. However, for those that don’t, it’s often a requested feature. C# has the internal keyword, much as methods or fields may be declared protected or private.
It’s perfectly reasonable that services need to interact with each other, whereas the “internal” concept (whether enforced in code or not) prevents this in the same way two independent services would prevent it. Just like independent services, some form of communication pattern is necessary. Once again, this reintroduces coupling, but when done in a codebase, it is possible to make coupling very explicit using the paradigm of event handlers.
In this case, a method that carries out an action dispatches an event. Rather than this being an interaction with a communication protocol such as stream, it is handled within the application code:
class UserService {
public function changePassword(User $user){
// code that changes password
$this->dispatch(new ChangedPassword($user->getId()));
}
}
In the application bootstrap:
// Audit-service bootstrap
$dispatcher->addListener(ChangedPassword::class, function($event){
$user_id = $event->getId();
$this->logAuditTrail($user_id);
})
Many frameworks (e.g., Symfony or Laravel in PHP) provide event listeners as part of their framework, but they are relatively straightforward to implement as well. This creates a specific relationship viewable in code (rather than via API contracts, or by trying to read five different repository configurations to work out what is responding to what) that still enforces segregation of domains.
One useful tip when dispatching events is to only use scalar data in the event. This pushes services to rely on the simplest data points from another service and adds another barrier to just utilising internal concepts of a data store, which may have to be changed in the future.
While it’s encouraging to see that domain isolation and clarity of coupling can be introduced in a monolith without rewriting it to microservices, there are still benefits to microservices that can’t be reproduced easily using just a monolith. Smaller services can be tested and deployed more rapidly. They can also use different infrastructure to suit specific use cases.
Fortunately, this benefit is still open to monoliths if we use the idea of satellite services. A satellite service combines elements of monolith and microservice. The satellite is functionally independent from the monolith. It can have its own repository (although shipping the code alongside the monolith may be simpler for our team), be in its own language, and have its own test suite. What makes this different from a microservice is that we avoid the risk of coupled communication by keeping the data flowing in a single direction.
The main challenge from microservices communication is the concept of a request and response, and so each service interaction requires the understanding of two sets of data formats. Service A needs to send data in the right format and understand the response. If service B can also talk to service A, this problem is doubled.
When using a satellite service, we try to offload a very specific piece of data processing to another service without creating dependencies in both directions.
All news about PHP and web development
For example, imagine a customer analytics application that wants to start ingesting data directly from third-party merchants. While this adds a valuable new feature, it also introduces a sudden infrastructure burden. The monolith that was originally built to support a data visualisation Saas platform is unlikely to handle a firehose of metrics from third-party websites, especially when that data requires real-time processing and storage.
This is where the satellite service comes in. Given a very specific known schema for how the application already needs its data processed and stored, a service can be written to provide this third-party ingest function separately, offloading processing to custom infrastructure before delivering the data to the base system. The data flows in one direction, and the satellite doesn’t need to know anything about how the data is eventually used. In the event the schema does need to change, the satellite will need to be upgraded first, but doesn’t need to be versioned; it can just output data in both old and new formats, and the application can choose which cases it uses.
The same would be true of a satellite service existing only to extract some specific piece of data and provide it to a third party (for example, as part of API middleware). Once again, if the data structure in the monolith is to change, the satellite must adapt first and be able to handle both old and new formats. There is no need here to maintain versions long term, as previous versions are set in stone and can remain without an extra maintenance burden, because the versioning is in the satellite, not the monolith. With a clear relationship between small satellites and the main monolith, we avoid the challenge of a mesh of services all needing different versions of one another (the so-called “big ball of mud”).

When microservices can use independent databases without running into the challenge of coupling between data stores, they gain some advantages. A big aspect is that many databases excel at specific requirements, but of course, no single database excels at everything.
The two main patterns of database usage are transaction processing and analytics. We often do these operations on the same or similar blocks of data, and relational databases can handle both well. However, depending on our throughput and load distribution, we may find that intensive write operations require one type of scaling while intensive read ones require another. Furthermore, these operations can interfere with each other; reads may be blocked by row-level blocks during writes, or the server may become overwhelmed in terms of processing power or memory when handling both operations.
In a traditional relational database, we can often solve this problem using read replicas, carrying out analytics on a secondary storage mechanism that can lag behind the primary, but in a way that this lag doesn’t cause problems for the data we’re trying to extract. The two databases can be sized differently, though they are often tied together in the same network, share the same data (mostly), and run on the same database platform.
A pattern we can learn from microservice architectures and apply back to our monolithic applications is Extract, Transform & Load. In these cases, we run a process that doesn’t just clone data between identical data stores on different hardware, but lets us modify the data and normalise or denormalise it for a different purpose. We can build ETL systems using a range of different options:
By recognising that the way we generate or store data can be decoupled from how we use it, even within a monolithic platform or one data store, we gain more options when it comes to how we use our data while avoiding the risk of different use cases interfering with one another.
The real challenge in building systems that remain manageable isn’t whether we choose a monolithic or microservices architecture, but how well we understand and manage the dependencies within our application, and, by extension, within our domain. If we’ve taken advantage of the simplicity and rapid development benefits of a monolith, we can still avoid the pitfalls of tight coupling by carefully structuring our code and enforcing clear boundaries around which parts of the application can interact, even within a single codebase.
We can use the best infrastructure or language for specific jobs by adopting satellite services, where we avoid excessive intercommunication problems by keeping data flowing in a single direction.
We have to acknowledge that our data storage will still be part of our communication layer and a place where accidental coupling can occur. However, clear rules within our application about where data belongs can help mitigate this, and our satellite services can be part of reformatting data for specific use cases when we need it.
If we’ve already built microservices, understanding our structure may not have fixed all our coupling. It is helpful to reformat or, at least, aim to make these dependencies explicit wherever they occur.
It’s natural for the architecture of the software we build to evolve around us. We can steward this evolution to manage complexity and reduce coupling, making it clear where our key dependencies are. Without needing to tear down our monolithic applications, we can take advantage of domain separation, satellite services, and data management strategies, building a healthy application by understanding the core principles of how services grow and communicate together.
moments: replacing a list of constants with an Enum, adding readonly to a DTO, or swapping a fragile switch for a robust match expression.
These features – Enums, Readonly, Named Arguments, Match, Property Hooks, and Attributes – are just a small set of the goodies made available in recent PHP releases, but they provide clearer intent, safer defaults, and expressive data. They turn quiet, insidious bugs into loud, fixable errors. They reduce boilerplate and let us focus on the business logic that actually matters.
So, here is the challenge: Pick one feature from this article. In your next pull request, try to replace a set of constants with an Enum, or use named arguments in a confusing function call. Start small. You will find that these small upgrades accumulate into big quality-of-life wins for you and your team.
Enjoy a better app, and an easier life!
The post Make Better Architecture Decisions appeared first on International PHP Conference.
]]>The post Modern PHP Features You’re Probably Not Using (But Should Be) appeared first on International PHP Conference.
]]>
It is a common scenario for all of us. I look around at conferences and see rooms full of people who have traveled far and wide, enthusiastic to learn, yet we all face the same blockers when we return to our desks. Despite our best intentions to stay current, the pressure to ship often forces us down the path of least resistance. It is safer, and often faster, to copy a familiar pattern from elsewhere in the codebase – even if it’s five years old – than to research and implement a new language feature.
This leads to a subtle ossification of our applications and us as developers. We end up in a situation where we might have 20 years of experience, but it looks suspiciously like the same year of experience repeated 20 times! This reality is reflected in the PHP ecosystem statistics, where sticky older versions like PHP 7.4 persist in the usage charts. Even those of us lucky enough to be running PHP 8.3 or 8.4 are often guilty of writing “PHP 7 code in a PHP 8 world”.
But modernisation doesn’t require a “stop the world” rewrite. By adopting specific, high-impact features introduced in recent versions, we can achieve what we all want: a strong, stable core for our applications. This article explores these features. This is not an exhaustive list of every shiny new toy, but more a curated guide to small changes that yield big wins for your applications and your overall quality of life!
When building a house, you start with the foundation. For developers, our foundation is our data. Is it behaving the way we think it is? Is it validated correctly? Can we rely on it?
Let’s look at a pattern we have all written a thousand times. You have an Article object with a status property. You write a conditional to handle the flow:
if ($article->status === 'published') {
publishToFeed($article);
} elseif ($article->status === 'revieew') { // Oops
sendToEditor($article);
} else {
logError('Invalid status: ' . $article->status);
}
If you look closely at the code above, you might notice the very eccentric spelling of ‘review’. Because this is a magic string – a string literal with special meaning but no formal definition – PHP happily accepts my fat-fingered typo. Your IDE can’t help you because, as far as it knows, you meant to type ‘revieew‘. The result is a subtle bug where articles that should be under review silently fall into the else block and trigger an error state.
These magic strings are fragile. They are hard to maintain, and they offer zero support from our tooling.
Enums (Enumerated Types) are often misunderstood as just a fancy way to group constants. While they do group constants, their real power lies in the fact that they are a type understood natively by PHP.
By defining an ArticleStatus Enum, we replace fragile strings with robust types:
enum ArticleStatus: string {
case Draft = 'draft';
case Review = 'review';
case Published = 'published';
}
Now we can type-hint our methods. Let’s consider the case of setting the status on an article, based on a form a user has submitted.
$status = $_POST['status'] ?? null;
// Some validation on $status, then...
$article->setStatus($status);
public function setStatus(string $status): void
{
// Do we need to validate $status again, just in case?
...
$this->status = $status;
}
Instead of accepting a generic string $status, we accept ArticleStatus $status. If we try to pass a typo or an invalid string to a function expecting this Enum, PHP throws a TypeError immediately.
$status = ArticleStatus::tryFrom($_POST['status] ?? null);
$article->setStatus($status);
public function setStatus(ArticleStatus $status): void
{
$this->status = $status;
}
Enums become even more powerful when we use them to centralise domain logic. Consider the presentation layer. Let’s say we have a search filter dropdown iterating over our statuses, capitalising the first letter to create a label: “Draft”, “Review”, “Published”.
<select name="status">
@foreach (['draft', 'review', 'published'] as $status)
{{ ucfirst($status) }}
@endforeach
</select>
Then, a new Marketing Manager joins the team. They decide that “Draft” isn’t punchy enough. They want it to say “Pre-publication”.
A developer picks up the ticket, finds the specific loop in the search filter, and hard-codes the change. But they miss the status pill on the article detail page. Suddenly, you have the same data concept labeled differently across your app. This leads to the kind of “Inconsistent Labels” Jira ticket that absolutely ruins your Friday afternoon.
Remember that Enums are more than just a collection of constants. We can solve this by adding a method directly to the Enum itself:
public function label(): string {
return match($this) {
self::Draft => 'Pre-publication',
self::Review => 'Awaiting Review',
self::Published => 'Live',
};
}
Now, the presentation layer doesn’t need to know what the label is; it just asks the Enum for its label. We update it in one place, and consistency flows through the entire application.
<!-- Our filter -->
<select name="status">
@foreach (ArticleStatus::cases() as $status)
<option value="{{ $status->value }}">
{{ $status->label() }}
</option>
@endforeach
</select>
<!-- Article detail page -->
<div class="pill">
{{ $article->status->label() }}
</div>
Input validation is another area where Enums shine. Typically, you might check if a submitted string exists within an array of valid options using in_array(). This leads to validation logic scattered throughout controllers, and inevitably, somebody updating the “magic list” in one place but not the others!
$status = $_POST['status'] ?? null;
// Check the magic list of accepted values..
if (!in_array($status, ['draft', 'review', 'published'])) {
throw new InvalidArgumentException('Invalid status = '.$status);
}
With Enums, you can use the tryFrom() method:
$status = ArticleStatus::tryFrom($_POST['status'] ?? '');
If the input matches a valid backing value, you get the Enum instance. If not, it returns null. This allows you to fail fast and eliminates the need for manual validation logic. You check it once, convert it to an Enum, and from that point forward, your application relies on a strict type rather than a vague string.
We often encounter data that should not change once it is created, such as a Data Transfer Object (DTO) or a Value Object representing a physical address.
Consider a standard Address class with public properties. You might instantiate an address for “123 Main St, Dublin, Ireland.” Ten lines later, a developer creates a bug by accidentally reassigning the country:
$address = new Address('123 Main St', 'Dublin', 'Ireland');
// ... some logic ...
$address->country = 'United Kingdom';
Taking the geopolitical implications of moving Dublin to the UK aside, this is a code issue we want to avoid. We don’t want the state of our objects to change unexpectedly after instantiation.
By adding the readonly modifier to the property (or the class in PHP 8.2), we instruct PHP to enforce immutability.
readonly class Address {
public function __construct(
public string $street,
public string $city,
public string $country
) {}
}
Now, if code attempts to modify the $country property after initialisation, PHP throws a loud, fatal error: Cannot modify readonly property. We have converted a potential silent logic failure – one that could corrupt tax calculations or shipping routes – into an immediate crash that forces us to fix the bug. This is part of a broader trend in these PHP updates – helping protect us from ourselves, and the previously silent failures which have tripped so many of us up over the years.
There is a nuance to readonly that can trip developers up: Objects within readonly properties can still be mutated unless they are also immutable.
Consider an Article class with a public readonly DateTime $publishedAt property. If you try to replace $publishedAt with a new DateTime object, PHP will stop you. However, if you call a modifier method on the object itself, PHP will allow it:
// This throws an error
$article->publishedAt = new DateTime(...);
// But this is allowed!
$article->publishedAt->modify('+1 month');
Even though the property is read-only, the internal state of the DateTime object is not. It is not “frozen” in stone. To achieve true immutability, you must ensure the types you use are also immutable, such as using DateTimeImmutable instead of DateTime. If you switch to DateTimeImmutable, the modify method returns a new object rather than changing the existing one, effectively locking down the state.
Once we have a stable core of data, we need to frame the structure of our application logic. We want to keep out the wind and the rain; we want to avoid ambiguity and silent failures that let bugs slip through unnoticed.
One challenge we have battled for years involves systems that look up data by ID. Originally, your find($id) function accepted an integer. Then, an SEO audit happened, and suddenly, you needed to support slugs. So, you removed the type hint and updated the DocBlock to say @param int|string $id. But as we know, comments aren’t contracts.
A DocBlock is a hint, not a guarantee. In a legacy codebase, nothing stops a developer from passing a float, an array, or a null into that function. The application won’t crash at the entry point, but will crash deep inside the logic, creating another type of silent failure that is painful to debug.
Union Types (PHP 8.0) allow us to move that logic from the comment into the code:
function find(int|string $id)
{
// ...
}
Now, the contract is enforced by the engine. If you pass an array, it explodes immediately.
Intersection Types (PHP 8.1) handle the opposite problem. Sometimes, you don’t care what an object is, only what it can do.
function handle(Cacheable & Responder $component)
{
$key = $component->getCacheKey();
$response = $component->respond();
// ...
}
Here, we aren’t forcing the component to inherit from a specific parent class. We are saying, “I don’t care what class you are, as long as you satisfy the Cacheable AND Responder contracts.” It eliminates ambiguity and creates precise, enforceable boundaries in your application structure.
The switch statement is a common source of bugs. Many developers have a mental model of it as being similar to an if/else, when in reality it is essentially a glorified goto statement. It suffers from two major issues:
Consider a switch statement, checking a status. If case ‘review‘ matches but you forget the break, the code falls through and executes case ‘published‘ immediately after. You end up with an article that is theoretically “Under Review” but is actually labeled “Published”.
$status = "review";
switch ($status) {
case "draft":
$label = "Draft";
case "review":
$label = "Under Review";
case "published":
$label = "Published";
}
echo $label;
// Expecting: "Under Review", but result:
// Published
To fix this in a switch statement, we have to litter the code with break statements, making the statement about 33% longer just to manage the boilerplate.
$status = "review";
switch ($status) {
case "draft":
$label = "Draft";
break;
case "review":
$label = "Under Review";
break;
case "published":
$label = "Published";
break;
}
echo $label; // "Under Review"!
The match expression addresses these flaws head-on. It uses strict comparison (===) and prevents fall-through automatically.
$label = match ($status) {
'draft' => 'Draft',
'review' => 'Under Review',
'published' => 'Published',
};
Perhaps most importantly, match must return a value. In a switch statement, if no case matches and there is no default, the code simply proceeds, potentially leaving variables undefined. With match, if no condition is met, PHP throws an UnhandledMatchError.
This turns a silent failure mode into something loud, aggressive, and in-your-face. While a fatal error sounds scary, it prevents those “niggly paper cuts” where invalid states persist in your database for months because the code silently failed to handle a specific case.
A powerful pattern is match(true). Instead of matching a value, you match the boolean true against a series of expressions. The first expression that evaluates to true wins. This is excellent for replacing complex if/elseif chains, such as determining age ranges:
$result = match (true) {
$age >= 65 => 'senior',
$age >= 25 => 'adult',
default => 'kid',
};
This syntax flips the logic around and makes complex conditionals much easier to scan. The PHP Docs include a nice example of using this structure to solve FizzBuzz, which is worth checking out.
We have all seen legacy functions that have grown “Christmas tree” ornaments over time – optional parameters tacked onto the end.
sendNotification($user, 'Subject', 'Body', true, false, true);
What do those booleans do? Is the first one “urgent”? Is the second one “send email”? Who knows? You have to dig into the function definition to find out.
Named arguments (PHP 8.0) solve this readability issue:
sendNotification(
user: $user,
subject: 'New Comment',
body: 'Someone replied!',
urgent: true,
ccTeam: false,
addTrackableLinks: true
);
This is self-documenting. It also makes your code refactor-safe. If the parameter order changes in the function definition, your named arguments will still work perfectly because they are bound by name, not position.
This is also helpful when you are dealing with a parameter that has picked up a lot of optional arguments over the years, and you only care about setting the last one. PHP historically doesn’t let you set a later optional argument and leave an earlier one empty, so often you’d end up copying in default values for the earlier parameters just to get to the one you care about. Then later on, someone changes the default, and your code is now setting values it didn’t really care about in the first place. Note $subject in the example below – we only ever wanted to set $metadata, but had to set a default subject, which has since changed in the constructor.
readonly class ArticleDTO
{
public function __construct(
public string $title,
public ArticleStatus $status,
public UserRole $authorRole,
public string $subject = 'Updated default subject',
public array $metadata = []
) {}
}
$article = new ArticleDTO(
'Modern PHP Features',
ArticleStatus::Published,
UserRole::Editor,
'Default subject', <-- Don't care, but needed to fill it ['key' => 'val'] <-- The one we care about setting!
);
With named parameters, because the order doesn’t matter, there’s no longer a need to set earlier optional values in the argument list. We can omit them altogether and only set the values we care about.
$article = new ArticleDTO(
title: 'Modern PHP Features',
status: ArticleStatus::Published,
authorRole: UserRole::Editor,
metadata: ['key' => 'val']
);
A word of warning: don’t overuse this for simple functions with one or two arguments, or your controllers will start to look like YAML soup! But for those complex legacy functions, it can be a lifesaver and a great help for readability.
Finally, we want to make our codebase a nice place to live. We want to plant a garden, paint the walls, and generally reduce the cognitive load required to work in the application.
Historically, creating a simple class in PHP involved a lot of boilerplate: defining properties, writing the constructor, and assigning arguments to properties. It was repetitive and prone to drift if you changed a variable name in one place but missed another.
class Article
{
private string $title;
private string $author;
private bool $published;
public function __construct(
string $title,
string $author,
bool $published
) {
$this->title = $title;
$this->author = $author;
$this->published = $published;
}
}
Constructor Property Promotion collapses this entire dance into a single definition:
class Article
{
public function __construct(
private string $title,
private string $author,
private bool $published
) {}
}
It is cleaner, shorter, and removes the noise. Personally, deleting 20 lines of boilerplate and replacing them with just 4 or 5 gives me a significant dopamine hit! We’re declaring the variables and their visibility in one go, while also allowing them to be automagically assigned.
A Note on AI: I recently ran an experiment asking several AI coding assistants (ChatGPT, Claude, Gemini) to generate a simple PHP class. Interestingly, almost all of them defaulted to the old, verbose, pre-PHP 8.0 syntax. When I challenged them on why they didn’t use promoted properties, they admitted they knew the better way, but “defaulted to the traditional way out of habit”. It was a fascinating Turing Test moment – the AI proved it was just as prone to bad habits as a human developer who hasn’t updated their knowledge in five years! This makes sense, with the AIs using statistical models – there are way more examples out there of older code than new. However, it serves as a reminder that we cannot blindly rely on AI to modernise our code. We have to know what features exist so we can ask for them explicitly.
A brand-new feature, Property Hooks, allows us to define get and set logic directly on a property. This eliminates the need for verbose getter and setter methods that clutter up our classes.
public string $fullName {
get => $this->first . ' ' . $this->last;
set => [$this->first, $this->last] = explode(' ', $value);
}
This keeps the logic for a property co-located with the definition of the property itself. It feels very similar to computed properties in languages like Swift or C#, showing how PHP continues to evolve by learning from other ecosystems.
In object-oriented PHP, it is easy to accidentally break an application when refactoring a parent class. If you rename a method in a parent class, but a child class was overriding that method, the child class’s method is now technically a new method, not an override. The link is broken silently, and the parent method starts executing instead of the child’s logic.
I recently encountered this with a subtle typo: a child class implemented handelRequest (misspelled), while the parent had handleRequest. There was no syntax error, just a silent failure where the wrong function ran.
By adding the #[Override] attribute, you explicitly tell the PHP engine: “I intend for this to override a parent method.”
#[Override]
public function handleRequest() { ... }
If the parent method is renamed or removed (or if you have a typo), PHP will complain, throwing a fatal error at compile time. This turns another quiet mistake into a loud one, allowing you to catch inheritance bugs instantly during development. As of PHP 8.5, this attribute can now be applied to properties, not just methods.
For 25 years, PHP developers have struggled to remember the specific invocation for reset(), end(), or array_shift() just to get the first or last item of an array. Is it passed by reference? Does it modify the array? I’ve been writing PHP for decades, and I still have to look it up!
PHP 8.4 introduces clear, descriptive helper functions: array_find, array_first, array_last, and array_any. While it’s easy to dismiss these changes as “syntactic sugar,” I like to think of it as the kind of sugar you get from fresh fruit, not the artificial stuff in a Diet Coke! It makes the code inherently more readable and reduces the cognitive load required to understand what an array operation is actually doing.
We have all written code that looks like this:
$result = str_shuffle(strtoupper(trim($input)));
To understand what is happening here, your brain has to work backwards. You start in the middle ($input), read out to trim, then out to strtoupper, and finally to str_shuffle. It is “inside-out” logic. We often try to fix this by putting each function on a new line, but then you are reading right-to-left and bottom-to-top.
PHP 8.5 introduces the Pipe Operator (|>), which allows us to structure this sequentially:
$result = $input
|> trim(...)
|> strtoupper(...)
|> str_shuffle(...);
Now, the code flows from top to bottom, left to right – exactly how we read text.
I realise many of you might be reading this thinking, “This looks great, Paul, but my production server is still running PHP 7.4, and there is no upgrade in sight. How will I ever get to use any of this new stuff in my app?”
The good news is that you can still use many of these features today via Polyfills. The Symfony team maintains a robust set of polyfills that backport modern PHP functions and classes to older versions. For example, if you want to use the new array_first() function but you are on PHP 8.0, you can install the polyfill. It checks if the function exists natively; if not, it provides a PHP userland implementation.
This allows you to write “future-proof” code right now. When your server eventually upgrades to the latest version, the polyfill steps aside, and your code uses the native, optimised implementation automatically. It is a seamless way to bridge the gap and start modernising your codebase incrementally without waiting for a massive infrastructure overhaul.
It is easy to look at features like Property Hooks (inspired by C#) or the Pipe Operator (common in F# and Elixir) and think that PHP is losing its identity. But the opposite is true.
Think of the English language. It famously borrows vocabulary from other languages. “Kindergarten” is German. “Government” is French. “Rodeo” is Spanish. English didn’t lose its identity by adopting these words; it became richer and more expressive by integrating concepts that worked well elsewhere.
PHP is doing the exact same thing. It is a mature, pragmatic language. It isn’t dogmatic. It observes what works well in the broader ecosystem – whether that’s immutability, type safety, or ergonomic syntax – and it adopts those features with a distinctly PHP flavor.
Modernising your legacy application is about embracing this evolution. It doesn’t require a “stop the world” rewrite. It happens in the small moments: replacing a list of constants with an Enum, adding readonly to a DTO, or swapping a fragile switch for a robust match expression.
These features – Enums, Readonly, Named Arguments, Match, Property Hooks, and Attributes – are just a small set of the goodies made available in recent PHP releases, but they provide clearer intent, safer defaults, and expressive data. They turn quiet, insidious bugs into loud, fixable errors. They reduce boilerplate and let us focus on the business logic that actually matters.
So, here is the challenge: Pick one feature from this article. In your next pull request, try to replace a set of constants with an Enum, or use named arguments in a confusing function call. Start small. You will find that these small upgrades accumulate into big quality-of-life wins for you and your team.
Enjoy a better app, and an easier life!
The post Modern PHP Features You’re Probably Not Using (But Should Be) appeared first on International PHP Conference.
]]>The post 30 Years of PHP, 25 Years of Testing appeared first on International PHP Conference.
]]>In this insightful talk, Sebastian takes us on a journey through 30 years of PHP and 25 years of testing, tracing the evolution of both the language and its ecosystem. From PHP’s early, humble beginnings to its status as a powerhouse of web development, and from the birth of PHPUnit to its place as an industry-standard testing framework, this session offers a unique perspective from someone who helped define these milestones.
All news about PHP and web development
Sebastian’s retrospective illustrates how PHP has transformed over 30 years from a small scripting tool into a robust, high-performance programming language with a vibrant ecosystem.
For attendees, the key takeaway is that continuous improvement and community-driven innovation are the foundation of PHP’s longevity. From major performance leaps in PHP 7 to the structured annual release cadence since PHP 5.4, every milestone underscores the value of iterative progress. The establishment of The PHP Foundation and its support for core development shows how open source can evolve into a professionally managed, sustainable ecosystem.
With 25 years of PHPUnit shaping PHP’s testing culture, Sebastian reinforces that automated testing is not just a best practice — it’s essential for professional software development.
Modern PHP developers can now leverage a powerful suite of tools — from PHPStan for static analysis to Infection for mutation testing — to achieve higher code quality and reliability. The learning for attendees is clear: testing enables confidence, and investing in testing tools and practices pays dividends in maintainability and scalability.
A central message of the session is that the PHP ecosystem thrives because of its collaborative and supportive community. Open dialogue through RFCs, transparent governance, and active sponsorship are what keep the language secure, relevant, and evolving.
Sebastian urges developers to contribute back — through code, documentation, funding, or advocacy — to ensure the long-term health of PHP’s infrastructure and open source projects. The lesson: the strength of PHP lies in its people as much as in its code.
Watch the full session recording below to gain inspiration from Sebastian’s journey and take away lessons on how testing, collaboration, and continuous improvement can shape the future of PHP — and your own development practice.
The post 30 Years of PHP, 25 Years of Testing appeared first on International PHP Conference.
]]>The post PHP 8.5 Features: Pipe Operator, Smarter Cloning & URL Handling Explained appeared first on International PHP Conference.
]]>
The first thing that we’re going to look at is closures in constant expressions. It was never possible to provide a default value in an array that defines a set of closures, that you then can call in order. With PHP 8.5, it is now possible to define such a list, as seen in the example below:
<?php
function slugger(
string $input,
array $callbacks = [
static function ($value) { return \strtolower($value); },
static function ($value) { return \preg_replace('/[^a-z]/', '-', $value); },
static function ($value) { return \trim($value, '-'); },
static function ($value) { return \preg_replace('/-+/', '-', $value); },
]
) {
foreach ($callbacks as $callback) {
$input = $callback($input);
}
return $input;
}
?>
In this example, the default value of the $callbacks array contains four closures, that the foreach loop then loops over to call. A user of this function can also provide their own array of callbacks.
There are some restrictions here, because these need to be static calls. That means they can’t be methods called on objects using $this. They also cannot use any values from outside the scope that these are defined in. That means that you can’t use “use” here, or short closures starting with fn().
All news about PHP and web development
In addition to this, it is now also possible to use first-class callables. A first-class callable is just a form of closure, and these can also never have any information coming in from the outside scope, like in this example:
<?php
function slugger(
string $input,
array $callbacks = [
\strtolower(...),
static function ($value) { return \preg_replace('/[^a-z]/', '-', $value); },
]
) {
foreach ($callbacks as $callback) {
$input = $callback($input);
}
return $input;
}
?>
Here we have replaced the static function ($value) { return \strtolower($value); } call to \strtolower(…). This is still quite a clunky way of creating such a function, where your $input is transformed through multiple function calls. To alleviate this, PHP 8.5 also introduces a new feature to resolve all of this: the new pipe operator (|>).
The pipe operator is a way of chaining methods together, called in order, with a value passed along between them. You can then also compose some interesting functions. With this, we can rewrite our slugger method to:
<?php
function slugger(string $input)
{
return $input
|> \strtolower(...)
|> (fn($x) => \preg_replace('/[^a-z]/', '-', $x))
|> (fn($x) => \trim($x, '-'))
|> (fn($x) => \preg_replace('/-+/', '-', $x));
}
?>
Like in the earlier examples, the slugger method takes the $input string, and then passes the input to strtolower(…), a short closure. Afterwards, it passes the result value of thhat to preg_replace(), trim(), and preg_replace() again.
However, because these functions take more than one argument, you can’t directly use the first class callable here. Pipes can only pass one value to the next call in the pipeline.
At the moment, to go around that, you have to wrap a short closure around the functions that would normally take more than one argument. The whole closure definition should also be wrapped again in parenthesis to avoid issues with priorities in the PHP code parser. That is why the example uses:
(fn($x) => \preg_replace('/[^a-z]/', '-', $x)).
This allows you to pre-define the other arguments to these functions. It uses the pipe operator that pipes the left-hand side to the closure, defined with fn($x) here, which then gets passed by the pipe operator to the third argument of the preg_replace() call as $x too.
Maybe in PHP 8.6 or later, there will be a better way of doing this, through a newly suggested feature called Partially Applied Functions.
With the pipe operator you have no insight to the value that gets passed from function to function or closure. This makes debugging a lot harder at first sight. With the first implementation, there was no way to get to this value, but through some changes in PHP, it is now possible for debuggers like Xdebug to see and present the intermediate stages of the pipe chain without you having to assign the intermediate value to a variable.
The next feature we’re going to look at is changes to the clone keyword. For a while, PHP has had read-only and final classes, which tend to be used as value objects to be passed around. Value objects are meant to be read-only and unmutable, but sometimes you might want to update these value objects to replace certain properties with new values.
Up until now, you couldn’t really do that without resulting to a hack by creating a wither method like:
<?php
final readonly class Response {
public function __construct(
public int $statusCode,
public string $reasonPhrase,
) {}
public function withStatus($code, $reasonPhrase = ''): Response
{
$values = get_object_vars($this);
$values['statusCode'] = $code;
$values['reasonPhrase'] = $reasonPhrase;
return new self(
...$values
);
}
}
?>
This only works in some situations, because this style of implementation relies on all the arguments being settable ad named arguments via the constructor when new self(…$values) is called.
The PHP development team originally wanted to create a specific new syntax addition to clone, to allow a new value object to be created with some properties modified. But a totally new syntax would complicate matters, as users, static analysis tools, and other tools would have to support it. Instead of a new dedicated syntax, the PHP developers have changed the clone keyword into a language construct/function hybrid.
This is needed, because up to now, a clone was a language construct only. This means that it was not possible to have arguments, as language constructs in PHP don’t really support that. It can only have a single expression as its right-hand-value.
The new feature in PHP 8.5 extends clone to make it into a function, which accepts two arguments. The first one being the object to clone, and the second one an array of property names and their new values:
<?php
final readonly class Response {
public function __construct(
public int $statusCode,
public string $reasonPhrase,
) {}
public function withStatus($code, $reasonPhrase = ''): Response
{
return clone($this, [
"statusCode" => $code,
"reasonPhrase" => $reasonPhrase,
]);
}
}
?>
The withStatus() method here accepts a $code argument, and an optional $reasonPhrase argument. The clone first creates a new Response object, with all the properties set to the values of the original object.
For each of the elements in the array passed as second argument, their values are going to be set on each property with the same name (statusCode and reasonPhrase), and in the same order as how they are present in the array.
Because these are internally just a normal assignment operation, it also means that each internal assignment will follow all the requirements for the values of these properties, including type checks, and visibility checks. Property hooks and __set methods are also called as with normal assignments. The only restriction that is lifted, is the “write-once” property of readonly properties.
The third big feature that we’re going to look at is URL parsing. For a long time, PHP has had the parse_url() function, which takes a URL or URI and parses this into its components. However, this function doesn’t follow any standard, has some strange PHP-isms while parsing the URL, and in general isn’t very useful for parsing URLs according to any standard, or using them safely in the modern web.
PHP 8.5 improves on this situation by introducing two new classes to parse, represent, and modify URLs. Each of the two variants is slightly different because they follow a slightly different standard. You can construct either of these by using “new”, but there is also a static parse() method. The constructor approach will throw an Uri\InvalidUriException when it encounters an invalid URI. The parse() factory method does not do this, and instead returns null.
The first one that we introduced is the Uri\WhatWg\Url class. Both the constructor and the parse() factory method parse the URL according to the WhatWG standard. Once parsed, you can access each of the component parts, create a new object with a component in the URL changed through a wither method, and then retrieve a fully assembled URL as a string again.
This class is best used if you need to do something with URLs that you’re going to embed into HTML. For example, when you regenerate URLs in a CMS, etc. Beyond the WhatWgUrl class, there is also the \Uri\Rfc3986\Url class. This parses the URL according to slightly different standards, in this case the RFC3986 standard.
This kind of URL is mostly used for server-to-server communication. Think of it as DSN parsing or outgoing HTTP requests that you make yourself. Both classes implement very similar methods that are not quite the same, because the concepts for each of these two different URL types are distinct.
Let’s have a look at our first one. In this example, we’re showing how to use the new Uri\WhatWg\Url class to parse our example URL. With the methods getScheme(), getAsciiHost(), getPath(), getQuery() and getFragment(), we can then get access to the original constituent parts:
<?php
// Parse URL:
$url = new \Uri\WhatWg\Url('https://friday-night-dinners.co.uk/archive/?search=local#artean');
// Show components:
echo $url->getScheme(), "\n";
echo $url->getAsciiHost(), "\n";
echo $url->getPath(), "\n";
echo $url->getQuery(), "\n";
echo $url->getFragment(), "\n";
?>
This outputs:
https
friday-night-dinners.co.uk
/archive
search=local
artean
It is also possible to modify these parts by calling wither methods as well. We continue from the previous example with:
<?php
$newUrl =
$url->withPath('/latest')
->withQuery('search=spanish')
->withFragment('');
?>
Please note that you need to assign the result from the wither methods to a new variable. The object is immutable and a new object will be returned from each of these methods. With the URL modified, we can finally convert it back to a full string:
<?php
echo $newUrl->toAsciiString();
?>
Which then outputs:
https://friday-night-dinners.co.uk/latest?search=spanish
Both the WhatWg\Url and Rfc3986\Url classes will know how to adapt the specific components according to the respective specification correctly. This also ensures that the strings that WhatWg\Url::toAsciiString() method, and its counterpart Rfc3986\Url::toString(), produce, are correctly formed as well.
Now let’s see some of the smaller features that have been added in PHP 8.5.
Constructor property promotions were introduced in PHP 8.1. These allow you to specify the visibility of a typed property inside the constructor’s argument definition, instead of having to define them separately, and then do the assignments from arguments to these properties manually in the constructor.
In PHP 8.4, we introduced property hooks that allow you to run some code when a property is being get or set with a user-defined function. With the inclusion of this, PHP also gained final properties, but these properties were not allowed to be defined in a constructor for property promotion.
PHP 8.5 now adds this functionality, as you can see in the following example:
<?php
class User
{
public function __construct(
final private string $first,
final private string $last,
) {}
final public string $fullName {
get => $this->first . " " . $this->last;
set { [$this->first, $this->last] = explode(' ', $value); }
}
}
$u = new User("Derek", "Rethans");
$u->fullName = "Derick Rethans";
echo $u->fullName, "\n";
?>
Extending the User class and redefining the type of the final private string properties $first and $last are now prohibited.
This new attribute enforced that during run time, the calling function consumes the returned value (by assignment, or it being passed on to another function as argument).
For example, if you have a DateTimeImmutable class and call the setDate method on it, you also will have to assign it to a new variable, otherwise the modification disappears. This is because DateTimeImmutable’s set methods return a new object and don’t modify the original one. Just like the two URL classes from earlier through their wither methods.
In PHP 8.5, the methods on the DateTimeImmutable class that return a new object now have this new NoDiscard attribute attached to them. When you don’t assign the return value to a new variable, you will get a run-time warning, like in this example:
<?php
$dt = new DateTimeImmutable();
$dt->setTime(9, 45);
?>
It will show you this warning to hint that you need to assign the newly created object to a variable:
Warning: The return value of method DateTimeImmutable::setTime() should either be used or intentionally ignored by casting it as (void), as DateTimeImmutable::setTime() does not modify the object itself.
As the message indicates, you can ignore the returned value by using the (void) cast, but at least with DateTimeImmutable, this makes no sense. You can use the #[NoDiscard] attribute in code that you write as well. It is an additional helper to make sure that you, or your library’s users, are not making mistakes in their code.
Note: The new WhatWg\Url and Rfc3986\Url classes have with* methods. This naming convention already signals that these return a new object, which is not something that is apparent with the set*-named methods from the DateTimeImmutable class. Because of this, the Url classes do not have the #[NoDiscard] attribute attached to them at the time of writing.
All news about PHP and web development
The filter extension has a new mode for when you validate incoming request variables. Normally, the filter_var() function would return false if it couldn’t validate the value. With an option flag to filter_var() you can make it instead return null if the input variable didn’t match with what you expected.
There is already a flag, FILTER_NULL_ON_FAILURE to make it return null in these situations, instead of false. This is useful because some probably values indeed run boolean false as a valid value. However, even with the FILTER_NULL_ON_FAILURE flag enabled, it makes for interesting and complex code. Instead, it is much better to be able to catch an exception.
PHP 8.5 introduces the FILTER_THROW_ON_FAILURE mode for filter_var(), which means that if an error is encountered while filtering the value to make sure it is correct, it will throw an exception, which you can then catch and handle in one go.
As you can see in this example here:
<?php
function validateUser(string $email, string $userId, string $userName) : bool
{
try {
filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE);
filter_var($userId, FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE);
filter_var(
$userName, FILTER_VALIDATE_REGEXP,
['options' => ['regexp' => '/^[a-z]+$/'], 'flags' => FILTER_THROW_ON_FAILURE]
);
return true;
} catch (\Filter\FilterFailedException $e) {
return false;
}
}
?>
PHP 7.3 introduced the array_key_first() and array_key_last() functions, to get either the first or the last key from an array. At that time, we didn’t introduce any functions to obtain the first and last array element values, because we weren’t quite sure whether we needed that.
However, it has now become clear that these are actually useful. This is why in PHP 8.5, we now have two new functions: array_first() and array_last(). These respectively return the first or last element values from an array, as I show you in this example here:
<?php
$timezone = new DateTimeZone("Europe/Kyiv");
$trans = $timezone->getTransitions();
var_dump(array_first($trans), array_last($trans));
?>
The last new change in PHP 8.5 that I want to focus on is that the OPcache extension can no longer be disabled. It is no longer a shared extension that you need to load specifically into PHP, and instead, it is built-in as a static extension like ext/standard or ext/date.
Although it is built-in, it does not mean that OPcache is also enabled by default. You still need to make the correct configuration settings to do so. Due to this tighter coupling, the PHP development team has now more freedom to utilise features in OPcache, such as its optimiser, in a more coherent fashion. It likely opens up avenues to improve performance, which is something that the PHP development team can now investigate.
The new features as presented here, are a high level overview of some of the bigger improvements and additions.
To see the full list, please visit our release page at https://www.php.net/releases/8.5/en.php, and the full change log at https://www.php.net/ChangeLog-8.php#PHP_8_5.
The post PHP 8.5 Features: Pipe Operator, Smarter Cloning & URL Handling Explained appeared first on International PHP Conference.
]]>The post How to Safely Upgrade Legacy PHP Applications to PHP 8 appeared first on International PHP Conference.
]]>All news about PHP and web development
Unless you have automated and thorough black box tests, you can’t tell for certain. Unit tests won’t be of much help because they or the testing framework might also not be compatible with the latest PHP. If you change both the tests and the system under test at the same time, regressions can slip through the cracks. The code needs to be exercised, whether manually or through a new black box test suite. I will discuss testing strategies a bit later in the article. For now, I will focus on some of the tools I use to help me with finding and understanding PHP compatibility issues.
The first tool has a self-explanatory name: PHPCompatibility. It’s open source, is mostly maintained by Juliette Reinders Folmer, and is currently in need of funding. It can detect a variety of compatibility issues, such as removed extensions, using class names that became reserved, forbidden call-time pass by reference, etc. Once installed by following the README, it can be executed on the command-line like this:
phpcs . --standard=PHPCompatibility --runtime-set testVersion 8.4
This will scan your current directory for compatibility issues with PHP 8.4. You can target other versions, so you could, for example, upgrade to PHP 7.4 before jumping to PHP 8.4. You could add more flags and options to the above command, such as -p to display progress, –colors to display colors in the output, and –extensions=php,inc,phtml to filter the files analyzed. The result looks like this:
FILE: /www/src/app/controllers/MyController.php
------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
------------------------------------------------------------------
166 | ERROR | Using 'break' outside of a loop or switch structure
| | is invalid and will throw a fatal error since PHP
| | 7.0
------------------------------------------------------------------
For some applications, this will be a very long list and will require much research to fix. Even then, it doesn’t find every possible issue. This is because PHPCompatibility doesn’t have cross-file awareness and doesn’t infer types. Even then, because the legacy code likely doesn’t have strict type declarations, you can’t detect errors until you get to the faulty scenario at runtime. In PHP 8, this represents a large portion of all issues because of the type strictness that it introduced. For example:
Fatal errors, although frustrating, are still preferred to unexpected logic changes, which are harder to catch and can lead to severe effects. It can also require much effort to understand how all these changes impact a given piece of code, since nobody might know what it’s supposed to do in the first place. This is why I usually avoid indiscriminately casting values before passing them to a PHP function, as it hides the real issue or completely changes the behavior. Example:
$string = array();
- $lowercase = strtolower($string);
+ $lowercase = strtolower((string) $string);
Although this change prevents PHP 8 from emitting a fatal error, it also changes the result from null to “array”, which can take the execution down a completely different path, potentially causing destructive actions such as overwriting data with this new string.
Tools such as PHPStan, Psalm, and Phan can detect some of the same things that PHPCompatibility can, but they are less focused on compatibility. However, they can complement PHPCompatibility with their ability to infer types. Here is an example of issues that PHPStan can detect, which can signal potential runtime issues on PHP 8:
These additional insights are very helpful, as give you a list that can serve as a basis for planning the upgrade project. However, PHPStan won’t be usable on all codebases, especially if it’s written in PHP 5, doesn’t use PSR-4, or has a lot of dead code. It will complain about pre-existing errors, even if those are false positives or inside dead code, and refuse to perform the full scan until you eliminate them. In large projects or in the early stages of an upgrade, eliminating all these issues might not be practical. PHPStan prioritizes preventing bugs over documenting them. Don’t be discouraged if this happens in your project. You can instead activate all warnings and notices on the original PHP version and exercise the code via a test suite, which I’ll discuss in the next part. The execution of the tests will generate logs. You can then research whether the warning or notice becomes a fatal error in PHP 8 and make a list that way.
Rector is a refactoring tool that I use when I need a very specific refactoring rule. In the PHP versions category, its main focus is on introducing modern features, which isn’t a priority if I want to get off an unsupported PHP version quickly. It also can be incorrect or incomplete, so it should be used carefully. For example, when replacing PHP 4 style constructors, it renames the method but doesn’t update the constructor calls. PHPStorm does this correctly, and I use PHPStorm to fix PHP 4 style constructors. Here are some examples of how I use Rector:
Some of the previously discussed issues can be detected and fixed by PHPStorm, which is a commercial product, but widespread enough to mention here. It has a multitude of useful inspections and quick-fixes, but not enough to replace the previous tools. It does have a Replace Structurally feature, which allows us to create simple yet syntax-aware replacements. For example, say I wanted to write a compatibility adapter for the fopen() function, but only when it’s called with 2 arguments. I would be able to search for all references to this function with exactly 2 arguments, which is not something one should attempt with regular expressions because of the potential complexity. Example: fopen((new MyClass($array[‘key’]))->getPath(), ‘r’). PHPStorm does the heavy lifting here with fopen($arguments$). I then tell it to replace it with Compatibility::fopen($arguments$). This allows me to make safe changes that don’t accidentally erase portions of the code or introduce parsing errors.
As you can see, every tool has its advantages and drawbacks, so you need to find how to best combine all these tools for your specific upgrade project. Even with all these tools, you still need to thoroughly test your code to ensure that the behavior didn’t change.
If the application doesn’t yet have a complete black box test suite, I recommend writing characterization tests. These will ensure that the application continues to behave the same way as on the old PHP version. For this, you would write tests that pass on the old PHP version with the unchanged codebase. Once you put the application on a new PHP version, the same tests will obviously fail. You would then combine the insights from the previously discussed tools, logs, and the newly created tests to fix the compatibility issues until the tests pass. The more thorough the automated test suite, the fewer manual tests you would need.
There are many tools to accomplish this, although I personally use Cypress due to the community size, abundance of plugins, ease of use, and great documentation. Installation instructions and tutorials are available on their website. The tests can run in the browser, which is useful for debugging, or headless on the command-line, which is useful to put in a continuous integration pipeline. Test cases will be written in JavaScript or TypeScript. Here is an example of a test (Listing 1).
describe('Checkout', () => {
it('Can add items to the shopping cart', () => {
const productName = 'Product 1';
cy.visit('/shop')
cy.contains('.product', productName)
.siblings('div')
.contains('button', 'Add to Cart')
.click();
cy.title().should('eq', 'My Cart');
cy.contains('.cart-item', productName).should('exist');
})
})
This test opens the shop, finds a specific product, finds and clicks the associated Add to Cart button, then ensures we end up in the cart with that product added to it. One advantage of these tests is that they won’t need to be changed even if you replace most of your libraries. In fact, you could even rewrite your entire application in a completely different language, although I don’t recommend it in most cases. In 23 years, I only recommended a rewrite twice, both times because the language was dead. PHP is very much alive, so it’s safer and less expensive to upgrade the code.
Another type of regressions you should look out for is performance. Some compatibility solutions might be more resource-intensive. Black box tests can measure little beyond the response time, but the application can be modified to inject performance metrics into the page whenever it detects a test environment. These changes need to be done starting with the original code so that the current performance can be captured. The captured metrics can then be added into the tests’ expectations. Example:
window.phpPerformance = {
memoryUsage: <?php echo $memoryUsage; ?>
};
Let’s say that the current application reports 20MB, and we want the new version to not exceed this value. Here’s an example assertion in a Cypress test:
cy.window().then((win) => {
const megabyte = 1024 * 1024;
expect(win.phpPerformance.memoryUsage)
.to.be.at.most(20 * megabyte);
});
The approach I privilege in PHP upgrades is one where I make minimal changes to individual expressions. Expressions are smaller than statements. For example:
Some expressions can be affected when moving to a new PHP version. It’s much easier to reason about an expression than it is about the state of an entire application, possibly across multiple HTTP requests. State can get extremely complex, especially if it doesn’t follow best practices and abuses globals, which is quite typical of the legacy applications I work with. If an individual expression, given the same values, exhibits the same behavior, then by extension, the entire application should continue to behave the same. With that in mind, I don’t need to understand each one of the millions of lines of code and how they interact. I reduce the application to its most basic elements and fix those.
Let’s take $object->property = ‘php’. It seems simple enough until the object is undefined. In PHP 8, this results in a fatal error. In PHP 4 through 7, it magically instantiates the object in that scope before assigning. If you’re lucky, you can initialize the variable just before. But what if it’s passed to the function, and you can no longer say with certainty whether it can be null? In a codebase which relied heavily on this magic behavior, I created a custom Rector rule to find all property assignments where the target object is not declared in the same scope. I then replaced those with Compatibility::initObject($object)->property = ‘php’, where the new method would check the object’s value at runtime and instantiate it if needed, making this expression retain its old behavior.
All news about PHP and web development
Native PHP functions became stricter in PHP 8. They would reject invalid types and values. In the past, such an input would typically return null or false, depending on the input. For example, in PHP 7, mb_strtolower() would return null if no arguments are provided, but false given an invalid encoding. Many PHP 8 functions are no longer capable of returning null or false. This is why most of my compatibility adapters check for these scenarios and return these values before calling the native function (Listing 2).
public static function mb_strtolower($string = null, $encoding = 'ISO-8859-1'): string|null|false
{
if (func_num_args() === 0) {
return null;
}
if (self::isValidEncoding($encoding) === false) {
return false;
}
return \mb_strtolower((string)$string, $encoding);
}
Can a full compatibility library be created for legacy PHP? Perhaps, but that would cause performance degradation. All the logic that you see above the native function used to be inside the native function, which was written in C. If we reproduce every single scenario that the function used to have, but in PHP, it would add significant overhead. Instead, I recommend focusing only on functions and scenarios that affect your code. If you never pass invalid encodings to this function, then there’s no point in validating it in this adapter. You can check whether a function can receive invalid input by logging the types and/or values at the top of the adapter, run your test suite, and analyze the log to understand how the application uses this function.
Most of these solutions make the code less pretty. However, the aim is to make safe changes and enable us to modify the compatibility adapters if we discover a new scenario that we didn’t account for, instead of having to undo all our changes inline. It makes the fix more maintainable. The goal, once the pressure to get off an unsupported PHP version is gone, is to refactor to clean the code so it doesn’t need these fixes in the first place.
A tool that I really like to probe functions and experiment with solutions is 3v4l, an online shell to run PHP code on multiple PHP versions. It makes it easy to compare outputs. I would, for example, supply all kinds of invalid input to a function and compare the outputs across versions. This tells me what scenarios I may need to put in the adapter. I can also write an adapter and test it there. Once satisfied with the adapter, I would write unit tests for it. I can, of course, achieve this locally by running multiple PHP versions, but I like the ability to then share those code snippets with my colleagues and on social media.
I see many developers disregard third-party code because they expect to simply replace it with the latest community version. That might not be possible or practical if, for example:
Once you make a list of all your dependencies, you need to determine the feasibility of using the latest community version. In some cases, you may find community-maintained forks of the legacy library or framework, which both preserves the old behavior and runs on the latest PHP. An example of that would be zf1-future, which is compatible with PHP 8.1, and is a close enough starting point. Such forks are common, especially for widely used libraries, since so much other legacy code depends on them. Remember to dig a bit deeper if your code uses an abandoned library.
It’s also possible to replace abandoned libraries with different ones or even develop your own. This might be an opportunity to have something that better satisfies today’s needs. An example of that might be replacing pChart 1.x, since version 2 was a complete rewrite, so your code won’t work with it. For example, if the purpose was to render charts in the browser, then a JavaScript library like Chart.js or Google Charts might work even better than PHP code generating static images.
Another type of third-party code we didn’t discuss yet is extensions. I reason about them in the same way I reason about PHP libraries. Does it exist for the latest PHP version? Does it behave the same? Are there replacements, either as extensions or as Composer packages? Even when you do find a replacement, make sure it behaves the same as in the legacy PHP version. For example mcrypt_compat is a Composer package that replaces the abandoned mcrypt extension. However, mcrypt, before PHP 5.6, would accept a shorter key and would simply pad it with ‘\0’ to get the required size. Starting in PHP 5.6, it would instead reject the shorter key and return false. For a codebase that has already encrypted everything using the zero-padded key, that would be problematic. To fix it, I got the author to add a PHPSECLIB_MCRYPT_TARGET_VERSION constant to allow this package to replicate the old behavior.
After the upgrade, it is still a good idea to address the underlying issue of using a key that’s too short, as it undermines security, but this approach gives us more granular control for a progressive modernization journey. It’s always better to have things a bit more secure now than wait for everything to be ready later.
Here is what I would like you to take away from this article:
I hope this advice helps you with your next PHP upgrade project. Happy coding!
Old PHP versions stop receiving security patches, making applications running on them vulnerable over time. Upgrading is necessary to maintain security and functionality.
PHPCompatibility is an open-source tool that scans PHP code for compatibility issues with specific PHP versions. It detects removed extensions, reserved class names, and invalid syntax such as using break outside of loops.
PHPCompatibility lacks cross-file awareness and type inference, so it may miss issues related to PHP 8’s stricter type system. Errors often remain undetected until runtime.
PHPStan and similar tools infer types and detect potential runtime issues such as undefined variables or static calls to instance methods. However, they may struggle with older codebases or those lacking PSR-4 structure.
Rector automates refactoring tasks and helps apply PHP version-specific changes. It can apply custom transformation rules, though it requires careful configuration and validation.
Creating characterization tests on the old PHP version helps preserve existing behavior after upgrade. Tools like Cypress can automate these tests in browsers or CI pipelines.
By injecting performance metrics into pages and validating them in test assertions, developers can ensure that performance does not degrade after the PHP upgrade.
Focusing on fixing individual expressions rather than entire states reduces the risk of introducing logic errors. Compatibility adapters can replicate legacy behavior safely.
Each dependency should be evaluated for PHP 8 compatibility. Options include using community forks, replacing with modern alternatives, or creating custom adapters.
While possible, a complete compatibility layer may introduce performance overhead. Developers should focus on only the functions and behaviors actively used by their application.
The post How to Safely Upgrade Legacy PHP Applications to PHP 8 appeared first on International PHP Conference.
]]>The post PIE: The Future of PHP Extension Management appeared first on International PHP Conference.
]]>All news about PHP and web development
The main advantage of this modular architecture lies in the ability to add advanced and targeted functionalities without requiring development from scratch. This allows developers to rely on proven components that are already optimized and widely tested by the community to address specific issues. This significant gain in time and efficiency allows developers to focus on business logic unique to each application rather than reinventing existing technical solutions.
The range of functionalities covered by PHP extensions is extremely broad and continues to expand with the evolving needs of web development. Among the most commonly addressed areas are connecting and interacting with a variety of database management systems, whether relational solutions like MySQL, PostgreSQL, or SQLite, or NoSQL databases. Image manipulation is also a well-served area, with extensions such as GD, which allows dynamic image creation and modification, and Imagick, based on the powerful ImageMagick library, offering more advanced features.
User session management, crucial for information persistence between requests, is greatly facilitated by dedicated extensions. Network communication, whether via standard protocols like HTTP through cURL or via low-level connections with sockets, also benefits from high-performance extensions.
It’s important to emphasize that some extensions go beyond simply adding functionalities and can constitute entire frameworks. This is the case with Phalcon, which is implemented as a PHP extension written in Zephir, thus offering superior performance and a reduced memory footprint compared to traditional PHP frameworks written in native PHP. We can add to this example by stating that Zephir is actually a kind of wrapper over the C language, aiming to facilitate the writing of PHP extensions specifically.
PHP extensions are a fundamental element of the language’s power and flexibility. They allow for building modern and performant web applications by leveraging a rich and mature ecosystem of specialized components, thus avoiding duplication of effort and promoting the reuse of quality code. It should also be noted that the PHP standard library, the famous SPL, is itself an extension of the language.
However, despite their importance and usefulness, PHP extensions sometimes have a tarnished reputation, primarily due to historical challenges associated with their installation and configuration. This negative perception is rooted in the complexity and heterogeneity of the installation processes encountered by developers.
In the past, adding an extension frequently involved manual compilation of the source code. This procedure required not only an understanding of compilation tools (like GCC or Make) but also the presence of appropriate development libraries on the underlying operating system. For less experienced developers or those unfamiliar with command-line environments, this step could be particularly intimidating and prone to frustrating errors.
Several factors have contributed to this complexity and the resulting bad reputation:
While Docker has contributed to the adoption of containers by standardizing development and production environments, thus simplifying the installation and management of PHP extensions via pre-configured images and one-line installation tools like Michele Locati’s Docker PHP Extension Installer (which supports over 150 extensions), this solution remains intrinsically linked to Docker. This coupling constitutes a major obstacle to its adoption as a universal solution for installing PHP extensions in various environments.
PECL, the command-line PHP extension manager, which aimed to simplify the installation of PHP extensions with a single command, similar to the simplicity brought by Composer for PHP dependency management, is now facing a growing negative image due to several significant weaknesses.
Recently, the PHP Foundation published an in-depth critical analysis of the tool, exposing fundamental problems that hinder its use and reliability. Among these major issues, the excessive slowness of the website, allowing package metadata Browse, is frequently cited by developers, which makes the addition of new extensions tedious and time-consuming at times. Furthermore, the website hosting extension metadata suffers from recurring maintenance issues, making its evolution extremely complex.
Another drawback raised by the PHP Foundation concerns the lack of package signing for those distributed via PECL. This lack of authenticity verification raises important security questions, as it exposes developers’ systems to an increased risk of installing malicious or compromised extensions. Unlike other modern package managers that integrate cryptographic signing mechanisms to guarantee software integrity and origin, PECL does not provide this essential assurance.
Furthermore, PECL proves unable to verify the compatibility of PHP versions installed on the system with the versions required by the extensions. This crucial shortcoming forces developers to manually perform compatibility checks, a complex and error-prone process. Installing an incompatible extension can lead to major malfunctions, runtime errors, or even instability of the entire PHP application. This lack of version dependency management makes the process of installing and updating extensions particularly delicate and significantly increases the risk of introducing problems into a production environment.
The need to manually navigate through different versions and their requirements makes PECL less attractive compared to more modern and integrated solutions. It’s not uncommon to have to regularly browse each version manually to ensure compatibility with your PHP version, or to have to rely on code repositories like GitHub to search through releases for the version you need to use.
This is an even greater challenge for maintainers of open-source applications using these extensions. We can cite Symfony, for example, which offers bridges with many extensions in many of its components. Being compatible with several versions of an extension is a challenge in itself, which is only greater when navigating through the different versions of extensions becomes a real hurdle.
In response to PECL’s limitations, the PHP Foundation took the initiative in late 2023 – early 2024 to develop a successor: PIE (PHP Installer for Extensions). The goal is to create a tool comparable to Composer, but specifically dedicated to managing PHP extensions. PIE aims to significantly simplify the installation and configuration of extensions with a single command line, freeing developers from the complexities traditionally associated with this task. James Titcumb is the lead developer in charge of the development and progress of this promising new tool.
Developed in PHP, PIE doesn’t just mimic Composer; it intrinsically leverages its architecture by relying on its public API. This deep integration goes beyond a simple technical dependency. The Composer ecosystem largely relies on Packagist, a central repository accessible at packagist.org. This centralized registry contains the essential information for all PHP libraries installable via Composer, thus facilitating dependency management for developers.
All news about PHP and web development
In a strategic initiative aimed at consolidating the PHP ecosystem, Packagist and The PHP Foundation have established a significant partnership. Packagist’s proven and robust infrastructure is now being utilized to host not only traditional Composer packages, but also to centralize PHP extension metadata.
This collaboration represents a major step forward towards unifying and simplifying dependency and extension management within the PHP community. By offering a single point of access for libraries and extensions, this synergy aims to improve the developer experience, reduce fragmentation, and strengthen the consistency of the PHP ecosystem as a whole. This approach facilitates the discovery, installation, and updating of various components, thus contributing to greater efficiency and productivity for PHP developers.
All extensions present in Packagist, and thus available and installable with PIE, are listed here.
Installing PIE is the essential first step to interacting with this tool. Comprehensive and detailed documentation regarding the installation process is available at the dedicated PIE GitHub page. Users primarily have two quick and efficient methods to install PIE: using the PHAR archive, which is a self-executing standalone, or using the Docker image provided by the development team.
The Docker image has the advantage of integrating all the necessary dependencies and tools to compile a PHP extension, thus offering an isolated and pre-configured environment. However, using Docker involves a more detailed and potentially less intuitive command syntax for novice users. For the sake of simplicity and clarity of the examples that will be presented later in this article, we will prioritize the use of the PHAR archive to illustrate PIE’s functionalities.
PIE organizes its operations around three fundamental commands, each fulfilling a specific function in the process of managing PHP extensions. These commands are: download, build, and install, followed by the name of the extension you wish to install. Understanding the role and interaction of each of these commands is essential to using PIE effectively.
The first command offered by PIE, named download, initiates the process of retrieving the source files of the specified PHP extension. Once the command is executed successfully, these sources are stored locally in PIE’s dedicated cache directory on your system.
It’s important to note a significant limitation regarding source compilation: this functionality is exclusively available on Linux and macOS operating systems. In the context of a Windows environment, the download command adopts a different approach. If a pre-compiled version of the extension, in DLL format, is provided by the extension maintainers, PIE will proceed to download it. The responsibility for providing this DLL file rests entirely with the maintenance teams of the relevant extension, thus ensuring compatibility with Windows systems.
# example of the `download` command
$ pie download xdebug/xdebug
The second command, designated by the term build, encompasses a dual functionality. If the sources of the targeted extension are not already present in PIE’s local cache (following a previous execution of the download command), PIE will download them first. Once the sources are available, whether they were downloaded previously or during the current build execution, the command immediately launches the extension compilation process.
It’s crucial to reiterate that this compilation step is a prerogative of Linux and macOS environments. Attempting to execute the build command under a Windows operating system will not affect the system, except potentially displaying an informative message in the command console.
# example of the `build` command
$ pie build xdebug/xdebug
The third essential command offered by PIE is the install command. This command orchestrates a set of actions aimed at integrating the PHP extension into your PHP installation. Initially, if the extension’s sources are not already present in the local cache, the install command will download them. Then, whether the sources have been freshly downloaded or previously retrieved, PIE will proceed with the compilation of the extension if this has not already been done.
The actual installation step consists of moving the compiled file, which usually has the .so extension (for Unix-like systems like Linux and macOS), to a specific directory where PHP is able to locate and load it. Finally, PIE handles the activation of the extension for your active PHP configuration. This activation results in a modification of the main PHP configuration file, traditionally named php.ini. PIE will add a new configuration line, following a standard format such as extension=extension_name.so, thus allowing PHP to load and use the extension during its execution.
# example of the `install` command, which will also enable the extension in your php.ini file
$ pie install xdebug/xdebug
As indicated previously, each command is followed by an extension name to install. As with libraries installed with Composer, you can use all the versioning constraints we are familiar with to ensure the correct version is installed. In all cases, PIE ensures the extension’s compatibility with your PHP installation, where PECL was lacking.
In reality, there’s a good chance you’ll use PIE more concisely by just calling pie install. This is perhaps the most requested feature since PIE’s development began. You’ve probably already seen lines like ext-json or ext-mbstring in the require directive of composer.json files.
These lines were previously informative, indicating that the project required an extension to be installed to function. Although Composer would warn you if the extension was missing from your installation, it couldn’t do much more. That era is over, because if you run the pie install command in the directory containing your project’s composer.json file, PIE will automatically detect the extensions necessary for your project to function correctly, and then install them if needed. It’s almost magical, and we’re lucky to see this superb evolution for the language’s 30th anniversary!
Some advanced options are available if you need to customize the extension installation process. For example, you have the option to choose which PHP binary you want to install the extension:
pie install xdebug/xdebug --with-php-path='/home/user/.my-php-executable'
It’s also possible to specify which php.ini file should be used for the extension installation:
pie install xdebug/xdebug --with-php-config='/home/user/.my-php/php.ini'
Just like packages installed with Composer, it’s possible to use local paths, Git repositories, or Private Packagist to store your extension sources, if they are not available on packagist.org:
pie repository:add path /path/to/your/local/extension
pie repository:add vcs https://github.com/some-user/some-extension
pie repository:add composer https://repo.packagist.com/your-packagist/
pie repository:add composer packagist.org
Finally, PIE offers support for the GH_TOKEN environment variable, allowing authentication to private GitHub repositories, as well as the uninstall command for uninstalling an extension installed with PIE.
In the face of historical challenges related to PHP extension installation and management, PIE emerges as a promising solution. Building on Packagist’s proven infrastructure and leveraging Composer’s API, PIE aims to significantly simplify the process, offering a modern and integrated alternative to PECL.
With its intuitive commands, automatic dependency management, and assured compatibility with PHP versions, PIE could mark a new era for PHP development, making extension addition as simple and seamless as dependency management with Composer. This evolution, particularly opportune for the 30th anniversary of the PHP language, represents a significant step forward towards a more coherent, efficient, and accessible ecosystem for all developers.
The article explains that traditional installation methods often involved manual compilation using tools like gcc and make, managing system dependencies, adapting to varying server environments, and dealing with inconsistent processes across extensions and systems. These hurdles made extension installation error-prone and opaque for many developers.
PECL suffers from an outdated website with slow metadata browsing, no package signing (raising security concerns), and lacks version compatibility checks—forcing developers to manually verify PHP-extension compatibility.
PIE (PHP Installer for Extensions) is a modern successor to PECL, developed by The PHP Foundation. It offers a Composer-like tool that simplifies installation, leverages Packagist infrastructure, and treats extensions as first-class packages with unified workflows.
PIE provides streamlined installation with one command, automated version compatibility checks, integration with Packagist, Composer-like experience, automatic php.ini configuration, and built‑in dependency handling.
PIE builds on Packagist’s centralized repository and uses familiar Composer APIs, enabling seamless discovery, installation, and updating of extensions with Composer-like version constraints.
PIE uses three main commands:
download — retrieves source files and caches them.build — compiles the extension from source (on Linux/macOS).install — performs download/build as needed, moves the compiled extension into place, and updates php.ini.If you run pie install in a project directory containing composer.json, PIE will detect required extensions (ext- entries), then auto-install and enable them—making installation nearly magical.
PIE supports:
--with-php-path)php.ini paths (--with-php-config)GH_TOKEN for private GitHub repospie uninstall.The post PIE: The Future of PHP Extension Management appeared first on International PHP Conference.
]]>