Longhorn PHP November 4, 2023
Tim Bond
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';
?>
<h1>Welcome</h1>
<?php
include 'greeting.php';
?>
<p>Goodbye!</p>
<?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';
?>
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
🤯
reset();
Autoloading
<?php
require 'path/to/MyClass.php';
new MyClass();
🚫
<?php
spl_autoload_register(function ($class) {
require 'classes/' . $class . '.class.php';
});
<?php
new MyClass();
Namespaces
Old:
class Acme_Logger { }
New:
namespace Acme; class Logger { }
Fatal error: Cannot declare class Logger, because the name is already in use
<?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
Today: 33 packages
composer require guzzlehttp/guzzle
{
"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
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 ├── Controller │ └── UserController ├── Entity │ └── User ├── Repository │ └── UserRepository └── Service └── UserService
src ├── User │ ├── User │ ├── UserController │ └── UserService └── Product ├── Product ├── ProductController └── ProductService
src ├── App │ ├── ConfigService │ ├── Logger └── Domain ├── User │ └── UserService └── Product
Type
vs.
Feature
vs.
Hybrid
Request
Controller
Service
Repository
Entity
Response
GET /api/users/123
Router
public/index.php
<?php
$router->get('/api/users/{id}', UserController::getUserById);
$router->post('/api/users/{id}', UserController::updateUserById);
$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
<?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
Validation lives here
Business logic lives here
Talks to repositories, APIs
<?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
<?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
<?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
<?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
<?php
namespace Acme\Controller;
class UserController {
public function createUser() {
$this->userService->createUser($_GET);
}
}
<?php
namespace Acme\Controller;
class UserController {
public function createUser(RequestInterface $request) {
$params = $request->getQueryParams();
$this->userService->createUser(
$params['firstName'],
$params['lastName']
);
}
}
PSR-7
✅ Fully unit testable
Pass arrays around
Create a value object
<?php
namespace Acme\Controller;
class OrderService {
public function createOrder(...) {
$user = $this->userRepository->findOrCreate(...);
$order = $this->orderRepository->create(...);
return new NewOrderDTO($user, $order);
}
}
<?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
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"
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
<?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();
}
}
<?php
// container bootstrap code
$container->register(DatabaseInterface::class, function() => {
return new Database('hostname', 'etc');
});
<?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
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