What Does A Modern PHP Application Look Like?

php[tek] May 16, 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)

  1. Authentication
  2. Benchmarking
  3. Caching
  4. Configuration
  5. Console
  6. Database
  7. Date and Time
  8. Encryption
  9. File Formats
  10. File System
  11. HTML
  12. HTTP
  13. Images
  14. Internationalization
  15. Logging
  16. Mail
  17. Math
  18. Networking
  19. Numbers
  20. Payment
  21. PEAR
  22. PHP
  23. Science
  24. Streams
  25. System
  26. Text
  27. Tools and Utilities
  28. XML
  29. 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

  1. Webserver points all traffic to public/index.php
  2. Framework bootstraps itself
  3. 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

Pass arrays around

Do

Create a value object

Don't

<?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

<?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;
  • 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 👍️ 🤔

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

Today 11:00 Database Abstractions and Where They Leak
Today 2:00 Domain-driven Design in PHP Workshop
Tomorrow 10:00 Dependency Injection for Mere Humans
Tomorrow 2:00 Leveling Up with Unit Testing
Thursday 11:00 Immutability to Save an Ever-Changing World
Thursday 3:00 Building a SOLID Foundation
Thursday 3:00 Building Enterprise Applications with Domain Driven Design (DDD)

Resources

phptherightway.com

github.com/php-pds/skeleton

Questions

What Does A Modern PHP Application Look Like?

By Tim Bond

What Does A Modern PHP Application Look Like?

php[tek] May 16, 2023

  • 563