What Does A Modern PHP Application Look Like?
Longhorn PHP November 4, 2023
Tim Bond
Who am I?
- Senior Software Engineer
- Frontend developer
(when I have to be) - Seattle is my home
- Cyclocross racer
Opinions ahead
<!--include /text/header.html-->
<!--getenv HTTP_USER_AGENT-->
<!--ifsubstr $exec_result Mozilla-->
Hey, you are using Netscape!<p>
<!--endif-->
<!--sql database select * from table where user='$username'-->
<!--ifless $numentries 1-->
Sorry, that record does not exist<p>
<!--endif exit-->
Welcome <!--$user-->!<p>
You have <!--$index:0--> credits left in your account.<p>
<!--include /text/footer.html-->
PHP/FI Code
<?php
require 'header.php';
?>
<!-- main page layout goes here -->
<?php
require 'footer.php';
?>
index.php
<h1>Welcome</h1>
<?php
include 'greeting.php';
?>
<p>Goodbye!</p>
include() and require()
<?php
echo 'Hello world!';
?>
index.php resume.php guestbook.php includes/ ├── header.php ├── footer.php └── functions.php
index.php
resume.php
guestbook.php
includes/
├── header.php
├── footer.php
└── functions.php
lib/
└── adodb
├── (more files)
└── adodb.inc.php
<?php
require_once 'includes/functions.php';
require_once 'lib/adodb/adodb.inc.php';
// database setup
require 'header.php';
?>
<!-- guestbook page layout goes here -->
<?php
require 'footer.php';
?>
guestbook.php
index.php resume.php guestbook/ ├── index.php ├── view.php └── submit.php includes/ ├── header.php ├── footer.php ├── database.php └── functions.php lib/ └── adodb └── adodb.inc.php
<?php
require '../includes/database.php';
require_once '../includes/functions.php';
// code to generate guestbook
guestbook/index.php
🤯
reset();
Goals
PHP 5.0 (2004)
- New* OOP
- Less focus on procedural code with global functions
PHP 5.1 (2005)
Autoloading
<?php
require 'path/to/MyClass.php';
new MyClass();
🚫
<?php
spl_autoload_register(function ($class) {
require 'classes/' . $class . '.class.php';
});
<?php
new MyClass();
PHP 5.3 (2009)
Namespaces
Old:
class Acme_Logger { }
New:
namespace Acme; class Logger { }
Fatal error: Cannot declare class Logger, because the name is already in use
Namespaces
<?php
namespace Acme;
spl_autoload_register(function ($class) {
include 'classes/' . $class . '.class.php';
});
<?php
use Acme\Logger;
use Zend\Controller;
// include autoloader files
new Logger();
new Controller();
✅ Loading classes
❓ Managing classes
Packages (2004)
- Authentication
- Benchmarking
- Caching
- Configuration
- Console
- Database
- Date and Time
- Encryption
- File Formats
- File System
- HTML
- HTTP
- Images
- Internationalization
- Logging
- Math
- Networking
- Numbers
- Payment
- PEAR
- PHP
- Science
- Streams
- System
- Text
- Tools and Utilities
- XML
- Web Services
Today: 33 packages
2012
Composer
- Dependency manager
-
composer require guzzlehttp/guzzle
-
- Dependency solver
- Gateway to Packagist
Autoloading with Composer
{
"autoload": {
"psr-4": {"Acme\\": "src/"}
}
}
<?php
require __DIR__ . '/vendor/autoload.php';
$obj = new Acme\MyClass();
<?php
namespace Psr\Log;
interface LoggerInterface {
public function emergency(string|\Stringable $message, array $context = []) : void;
public function alert(string|\Stringable $message, array $context = []) : void;
public function critical(string|\Stringable $message, array $context = []) : void;
public function error(string|\Stringable $message, array $context = []) : void;
public function warning(string|\Stringable $message, array $context = []) : void;
public function notice(string|\Stringable $message, array $context = []) : void;
public function info(string|\Stringable $message, array $context = []) : void;
public function debug(string|\Stringable $message, array $context = []) : void;
public function log($level, string|\Stringable $message, array $context = []) : void;
}
<?php
namespace Acme\SDK;
class AcmeHttpClient {
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
public function doSomething() {
$this->logger->debug('Started doing something');
}
}
✅ Loading files without include
✅ Loading 3rd party code
✅ Code plays well with others
❓ Structure our own app
3 Kinds of Apps
- Libraries
- e.g. Guzzle, Monolog
- Full Stack apps
- APIs
index.php resume.php guestbook/ ├── index.php ├── view.php └── submit.php includes/ ├── header.php ├── footer.php ├── database.php └── functions.php lib/ └── adodb └── adodb.inc.php
- 📁 src/
- all PHP code needed to run the project
- 📁 public/
- Point the webserver here
- 📁 tests/
- Unit, Integration, System
- 📁 vendor/
- Never checked in to version control!
- 📁 docs/
- 📁 bin/
- 📁 config/
- 📁 templates/
- 📄 composer.json
- 📄 composer.lock
- 📄 README(.*)
- 📄 CONTRIBUTING(.*)
- 📄 CHANGELOG(.*)
- 📄 INSTALLING(.*)
- 📄 LICENSE(.*)
src ├── Controller │ └── UserController ├── Entity │ └── User ├── Repository │ └── UserRepository └── Service └── UserService
Group by type
src ├── User │ ├── User │ ├── UserController │ └── UserService └── Product ├── Product ├── ProductController └── ProductService
Group by feature
src ├── App │ ├── ConfigService │ ├── Logger └── Domain ├── User │ └── UserService └── Product
Hybrid grouping
Type
vs.
Feature
vs.
Hybrid
Controllers?
Services?
Repositories?
Entities?
Request
Controller
Service
Repository
Entity
Response
GET /api/users/123
Router
Router
- Webserver points all traffic to
public/index.php
- Framework bootstraps itself
- Route file is invoked to determine which class/method to call
<?php
$router->get('/api/users/{id}', UserController::getUserById);
$router->post('/api/users/{id}', UserController::updateUserById);
Router
$app->group('/api', function (RouteCollectorProxy $api) {
$api->group('/users/{id}', function (RouteCollectorProxy $users) {
$users->patch('', UserController::UpdateUser);
$users->post('/reset-password', UsersController::ResetPassword);
});
});
$router->patch('/api/users/{id}/', UserController::UpdateUser);
$router->post('/api/users/{id}/reset-password', UsersController::ResetPassword);
🚫
{
-
Accepts a request
-
Rejects obviously invalid requests
-
ZERO validation, ZERO business logic
-
Passes off to service
-
later...
-
Receives response from service, formats for display
Controller
<?php
namespace Acme\Controller;
class UserController {
// maps to GET /api/users/{id}
public function getUserById(string $id) {
if(empty($id)) {
throw new BadRequestException("ID is required");
}
$user = $this->userService->getById($id);
$this->view->render($user);
}
}
-
UserController
-
getUserById
-
createUser
-
updateUser
-
-
AbstractUserAction
-
GetUserAction
-
CreateUserAction
-
UpdateUserAction
-
Controllers vs Actions
-
Validation lives here
-
Business logic lives here
-
Talks to repositories, APIs
Service
<?php
namespace Acme\Service;
class UserService {
public function getUserById(string $id) {
if(!is_numeric($id)) {
throw new DomainException("ID must be a number");
}
if($id < 1) {
throw new DomainException("ID must be a positive number");
}
return $this->userRepository->getById((int)$id);
}
}
-
Talks to the data storage
-
Only accessed from services--never controllers
-
Treat incoming data as sanitized
-
"Hydrates" entities to give back to the service
Repository
<?php
namespace Acme\Repository;
class UserRepository {
public function getUserById(int $id) : User {
$sql = 'SELECT * FROM users WHERE id = ? LIMIT 1';
$result = $this->connection->execute_query($sql, [$id]);
$row = $result->fetch_object();
return new User($row->id, $row->name);
}
}
-
"an object that has an identity, which is independent of the changes of its attributes"
-
Dumb "containers" for data--no functionality
Entity
<?php
namespace Acme;
class User {
public function __construct(
public readonly int $id,
public readonly string $name,
) {
}
}
-
"an object that has an identity, which is independent of the changes of its attributes"
-
Dumb "containers" for data--no functionality
-
Cannot be created in an invalid state
Entity
<?php
new User(1, 'Me');
new User(null, 'Nobody');
🚫
-
Data mapper
-
Clear definition of boundaries
-
Each class does one thing
-
-
Active record
-
One class does everything
-
User::create(...), User::getById(...), User::setActive()
-
-
Easy to leak logic into
-
Active Record vs
Data Mapper
<?php
namespace Acme\Controller;
class UserController {
public function createUser() {
$this->userService->createUser($_GET);
}
}
Don't
<?php
namespace Acme\Controller;
class UserController {
public function createUser(RequestInterface $request) {
$params = $request->getQueryParams();
$this->userService->createUser(
$params['firstName'],
$params['lastName']
);
}
}
Do
PSR-7
✅ Fully unit testable
Pass arrays around
Do
Create a value object
Don't
<?php
namespace Acme\Controller;
class OrderService {
public function createOrder(...) {
$user = $this->userRepository->findOrCreate(...);
$order = $this->orderRepository->create(...);
return new NewOrderDTO($user, $order);
}
}
On the return path
<?php
namespace Acme\DTO;
class NewOrderDTO {
public function __construct(
public readonly User $user,
public readonly Order $order,
) {
}
}
// in the controller
$dto = $service->createOrder(...);
$dto->user->name;
$dto->order->number;
-
A method returns one thing
- False means the opposite of true, not "it didn't work"
- Throws when it fails
- Exceptions should be specific
- Different handling for different failures
False isn't for failure
-
Unit tests
-
Run entirely offline, connect to nothing
-
"Fake" responses from databases, APIs
-
#1 way to catch regression bugs
-
-
Integration tests
-
Test the code in a live enviornment
-
-
E2E tests
-
Automated "manual testing"
-
Tests
Unit | Integration | E2E | |
---|---|---|---|
Library | ✅ | ❌ | ❌ |
Full Stack | ✅ | 👍️ | 👍️ |
API | ✅ | 👍️ | 🤔 |
-
Old method: Singletons
-
Create a container for dependencies
-
Services
-
Logger
-
API client
-
App settings
-
-
Container code lazily instantiates things
Dependency Injection
<?php
class Database {
private self $instance = null;
public function getInstance() : self {
if (!$this->instance) {
$this->instance = new self();
}
return $this->instance;
}
}
<?php
class UserRepository {
private Database $database;
public function __construct() {
$this->database = new Database();
}
}
Dependency Injection - Container
<?php
// container bootstrap code
$container->register(DatabaseInterface::class, function() => {
return new Database('hostname', 'etc');
});
Dependency Injection - Usage
<?php
class UserRepository {
public function __construct(
private DatabaseConnectionInterface $db,
) {
}
}
<?php
class UserRepository {
public function __construct(Container $container) {
$this->database = $container->get(Database::class);
}
}
🚫
-
Code comments: how the code works
-
Commit comments: why the change was made
-
Further reading: "How to Write a Git Commit Message" by Chris Beams
Comments
Software's job is to get stuff done
The deliverable is the product, not the code
There is no “right” architecture...
This isn’t an excuse to never make things better, but instead a way to give you perspective. Worry less about elegance and perfection; instead strive for continuous improvement and creating a livable system that your team enjoys working in and sustainably delivers value.
—Justin Etheredge
Questions
What Does A Modern PHP Application Look Like? Longhorn PHP
By Tim Bond
What Does A Modern PHP Application Look Like? Longhorn PHP
Longhorn November 4, 2023
- 69