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)

  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

<?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);
    }
}

🚫

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