Modern PHP

Mexico City - virtual

Sep.30, 2021

http://talks.php.net/etsymex21

Rasmus Lerdorf
@rasmus

ETSY IS A PHP SHOP

Yes, but...

we use a lot of

Javascript, Typescript, Python, Ruby, C, C++, Java, Go, Objective C, Swift, Kotlin, Rust, Scala, Dart

as well

Web Serving Stack

  • Linux, Apache, MySQL, PHP
  • Memcache, Gearman, StatsD, Vitess, Redis, Kafka, Varnish
  • GCP, Terraform

API First!

Framework?

We built our own over the years

(bad idea, don't do that)

Request Routing

https://etsy.com/awesome/123

.htaccess Apache rewrite rule

RewriteRule ^awesome/(\d+)$   /awesome.php?id=$1 [L,NC,QSA]

awesome.php

require 'bootstrap.php';
$request  = HTTP_Request::getInstance();
$response = HTTP_Response::getInstance();
$controller = new Awesome_Controller();
$controller->doCoolThings($request, $response);

Code/Awesome/Controller.php

class Awesome_Controller extends Controller_Base {
  public function doCoolThings($request, $response) {
    $id = $request->getGet('id', 0);
    if (!$id) {
      $response->redirect_error(Constants::ERROR_NOT_FOUND);
      return;
    }
    $thing = EtsyORM::getFinder('Thing')->findById($id);
    $stuff = Api::endpoint('AwesomeStuff', [$thing->id, 'max'=>10]);
    $this->renderViewTree(New Awesome_View($thing, $stuff));
  }
}

Awesome_View

class Awesome_View implements Neu_View {
    const TEMPLATE = "/templates/awesome/main.mustache";
    use Neu_Traits_DefaultView;

    public function __construct(AwesomeThing $thing, array $stuff) {
        $this->thing = $thing;
        $this->stuff = $stuff;
    }
    public function getCssFiles(): array {
        return [ '/awesome/main.scss' ];
    }
    public function getTemplateData(): array {
        return [ 'thing_id' => $this->thing->id,
                 'thing_name' => $this->thing->name,
                 'stuff' => $this->stuff ];
    }
}

templates/awesome/main.mustache

<div>
    <p>{{thing_name}} ({{thing_id}})</p>
    <ul>
    {{#stuff}}
        <li>{{id}} {{description}}</li>
    {{/stuff}}
    </ul>
</div>

Static Analysis



github.com/phan/phan

Install with composer

$ composer require --dev phan/phan

Create .phan/config.php

return [
    'target_php_version' => '8.0',
    'directory_list' => [ 'src/' ],
    "exclude_analysis_directory_list" => [ 'vendor/' ],
];
$ ./vendor/bin/phan

Phan in Browser

phan.github.io/demo/

Dependency Graph Plugin

pdep example

Daemon mode

$ phan --daemonize-tcp-port default &
[1] 28610
Listening for Phan analysis requests at tcp://127.0.0.1:4846
Awaiting analysis requests for directory '/home/rasmus/phan_demo'

$ vi src/script.php
$ phan_client -l src/script.php
Phan error: TypeError: PhanTypeMismatchArgument: Argument 1 (union) is array{0:1} but \C::fn() takes int|string defined at src/script.php:8 in src/script.php on line 14
Phan error: TypeError: PhanTypeMismatchArgument: Argument 3 (shaped) is array{max:10} but \C::fn() takes array{mode:string,max:int} defined at src/script.php:8 in src/script.php on line 16

vim integration

phpspy

Low-overhead sampling profiler

https://github.com/adsr/phpspy

Sample frequency in nanoseconds (or Hz)

$ phpspy -s 200000000 -- php -r 'sleep(1);' 
0 sleep <internal>:-1
1 <main> <internal>:-1

0 sleep <internal>:-1
1 <main> <internal>:-1

0 sleep <internal>:-1
1 <main> <internal>:-1

0 sleep <internal>:-1
1 <main> <internal>:-1

0 sleep <internal>:-1
1 <main> <internal>:-1

process_vm_readv: No such process

Attach to a running process

$ sudo phpspy -r -p $(pgrep -n php-fpm)

0 wp_installing /var/www/wordpress/wp-includes/load.php:944
1 wp_load_alloptions /var/www/wordpress/wp-includes/option.php:189
2 get_option /var/www/wordpress/wp-includes/option.php:90
3 create_initial_taxonomies /var/www/wordpress/wp-includes/taxonomy.php:43
4 WP_Hook::apply_filters /var/www/wordpress/wp-includes/class-wp-hook.php:286
5 WP_Hook::do_action /var/www/wordpress/wp-includes/class-wp-hook.php:310
6 do_action /var/www/wordpress/wp-includes/plugin.php:453
7 <main> /var/www/wordpress/wp-settings.php:450
8 <main> /var/www/wordpress/wp-config.php:89
9 <main> /var/www/wordpress/wp-load.php:37
10 <main> /var/www/wordpress/wp-blog-header.php:13
11 <main> /var/www/wordpress/index.php:17
# 1537119612.459615 /index.php p=1 /var/www/wordpress/index.php -

0 mysqli_query <internal>:-1
1 wpdb::_do_query /var/www/wordpress/wp-includes/wp-db.php:1924
2 wpdb::query /var/www/wordpress/wp-includes/wp-db.php:1813
3 wpdb::get_results /var/www/wordpress/wp-includes/wp-db.php:2488
4 _prime_comment_caches /var/www/wordpress/wp-includes/comment.php:2871
5 WP_Comment_Query::get_comments /var/www/wordpress/wp-includes/class-wp-comment-query.php:427
6 WP_Comment_Query::query /var/www/wordpress/wp-includes/class-wp-comment-query.php:346
7 get_comments /var/www/wordpress/wp-includes/comment.php:226
8 WP_Widget_Recent_Comments::widget /var/www/wordpress/wp-includes/widgets/class-wp-widget-recent-comments.php:99
9 WP_Widget::display_callback /var/www/wordpress/wp-includes/class-wp-widget.php:372
10 dynamic_sidebar /var/www/wordpress/wp-includes/widgets.php:743
11 <main> /var/www/wordpress/wp-content/themes/twentyfifteen/sidebar.php:41
12 load_template /var/www/wordpress/wp-includes/template.php:688
13 locate_template /var/www/wordpress/wp-includes/template.php:647
14 get_sidebar /var/www/wordpress/wp-includes/general-template.php:110
15 <main> /var/www/wordpress/wp-content/themes/twentyfifteen/header.php:49
16 load_template /var/www/wordpress/wp-includes/template.php:688
17 locate_template /var/www/wordpress/wp-includes/template.php:647
18 get_header /var/www/wordpress/wp-includes/general-template.php:41
19 <main> /var/www/wordpress/wp-content/themes/twentyfifteen/single.php:10
20 <main> /var/www/wordpress/wp-includes/template-loader.php:74
21 <main> /var/www/wordpress/wp-blog-header.php:19
22 <main> /var/www/wordpress/index.php:17
# 1537119612.459615 /index.php p=1 /var/www/wordpress/index.php -

Memory usage on stack frames

$ sudo phpspy -m php src/phan.php

0 Phan\Analysis::parseNodeInContext /home/rasmus/phan/src/Phan/Analysis.php:176
1 Phan\Analysis::parseNodeInContext /home/rasmus/phan/src/Phan/Analysis.php:176
2 Phan\Analysis::parseNodeInContext /home/rasmus/phan/src/Phan/Analysis.php:176
3 Phan\Analysis::parseNodeInContext /home/rasmus/phan/src/Phan/Analysis.php:176
4 Phan\Analysis::parseFile /home/rasmus/phan/src/Phan/Analysis.php:63
5 Phan\Phan::analyzeFileList /home/rasmus/phan/src/Phan/Phan.php:94
6 <main> /home/rasmus/phan/src/phan.php:1
# mem 119159776 123721960

0 ast\parse_code <internal>:-1
1 Phan\AST\Parser::parseCode /home/rasmus/phan/src/Phan/AST/Parser.php:42
2 Phan\Analysis::parseFile /home/rasmus/phan/src/Phan/Analysis.php:63
3 Phan\Phan::analyzeFileList /home/rasmus/phan/src/Phan/Phan.php:94
4 <main> /home/rasmus/phan/src/phan.php:1
# mem 82471616 123721960

Top-like output mode

Generate a flame graph

$ phpspy phan > /tmp/output
$ cat /tmp/output | stackcollapse-phpspy.pl | flamegraph.pl > flame.svg
Use a newer browser, please

Let's deploy it!

Atomic

No performance hit

  • No restarts
  • No LB removal
  • No thundering herd
  • Cache reuse

Must be able to serve two versions of the site concurrently!

Requests that begin on DocumentRoot A must finish on A

Set the DocumentRoot to symlink target!

Easy with nginx

fastcgi_param DOCUMENT_ROOT $realpath_root

Apache

github.com/etsy/mod_realdoc

Avoid hardcoding full paths

Watch your include_path setting

incpath extension can resolve your include_path for you

https://github.com/etsy/incpath

Version all static assets

DB Schema changes need special care

Version Support

Active Support Regular releases and security fixes
Security Fixes Only security fixes
End of Life No longer supported

PHP 8.0

Named Arguments

htmlspecialchars($string, double_encode: false);

// instead of

htmlspecialchars($string, ENT_COMPAT | ENT_HTML401, 'UTF-8', false);

Constructor Property Promotion

class User {
    function __construct(
        public string $first_name,
        public string $last_name,
        private string $password = "",
        protected int $group = 1
    ) { }
}

$u = new User("Rasmus", "Lerdorf", group:2);
echo $u->first_name;  // Rasmus

Nullsafe Operator with short-circuiting

Similar to ?. operator in Javascript, C# and Swift

$country = $session?->user?->getAddress(geoip())?->country;

// instead of

if ($session !== null) {
    $user = $session->user;

    if ($user !== null) {
        $address = $user->getAddress(geoip());

        if ($address !== null) {
            $country = $address->country;
        }
    }
}

Match Expression

$statement = match ($this->lexer->lookahead['type']) {
    Lexer::T_SELECT => $this->SelectStatement(),
    Lexer::T_UPDATE => $this->UpdateStatement(),
    Lexer::T_DELETE => $this->DeleteStatement(),
    default => $this->syntaxError('SELECT, UPDATE or DELETE'),
};
// Throws UnhandledMatchError on no match and no default expr

// instead of
switch ($this->lexer->lookahead['type']) {
    case Lexer::T_SELECT:
        $statement = $this->SelectStatement();
        break;
    case Lexer::T_UPDATE:
        $statement = $this->UpdateStatement();
        break;
    case Lexer::T_DELETE:
        $statement = $this->DeleteStatement();
        break;
    default:
        $this->syntaxError('SELECT, UPDATE or DELETE');
        break;
}

Union Types

class Store {
    private static $data = [];

    /**
     * @param int|string $key
     * @param int|float|string $val
     */
    static function add($key, $val): void {
        if(!(is_int($key) || is_string($key))) {
            throw new TypeError("Key must be an int or a string");
        }
        if(!(is_int($val) || is_float($val) || is_string($val))) {
            throw new TypeError("Value must be an int, float or a string");
        }
        self::$data[$key] = $val;
    }
    /**
     * @param int|string $key
     * @return int|float|string
     */
    static function get($key) {
        return self::$data[$key];
    }
}

Union Types

<?php
class Store {
    private static $data = [];

    static function add(int|string $key, int|float|string $val): void {
        self::$data[$key] = $val;
    }
    static function get(int|string $key): int|float|string {
        return self::$data[$key];
    }
}
Store::add('player2', [1,2,3]);
// TypeError: Store::add(): Argument #2 ($val) must be of
//                          type string|int|float, array given

weakMap

Map objects to arbitrary values without preventing GC

class Endpoint {
    function __construct(public string $url, ?callable $dfunc=null, array $opts = []) {
        $this->context = stream_context_create($opts);
        $this->dfunc = $dfunc ? $dfunc : fn($x)=>$x;
    }
}

class Api {
    static public ?weakMap $cache = null;
    static public function fetch(Endpoint $ep): string|object {
        if(!self::$cache) self::$cache = new weakMap;
        return self::$cache[$ep] ??=
               $ep->dfunc->call($ep, file_get_contents($ep->url, context:$ep->context));
    }
}

$xkcd = new Endpoint("http://xkcd.com/info.0.json", fn($x)=>json_decode($x, associative:false));
$joke = new Endpoint("https://icanhazdadjoke.com/", opts:['http'=>['header'=>"Accept:text/plain"]]);
echo '<img src="'.Api::fetch($xkcd)->img.'" alt="'.Api::fetch($xkcd)->alt.'">'."\n";
echo Api::fetch($joke) . "\n";
echo Api::$cache->count() . "\n"; // 2
unset($xkcd);
echo Api::$cache->count() . "\n"; // 1
echo Api::fetch($joke) . "\n";    // Same bad joke
$joke = new Endpoint("https://icanhazdadjoke.com/", opts:['http'=>['header'=>"Accept:text/plain"]]);
echo Api::fetch($joke) . "\n";    // New bad joke
echo Api::$cache->count() . "\n"; // 2?
gc_collect_cycles();              // Force gc
echo Api::$cache->count() . "\n"; // 1

Attributes

use Doctrine\ORM\Attributes as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
class User {
    #[ORM\Id, ORM\Column("integer"), ORM\GeneratedValue]
    private $id;

    #[ORM\Column("string", ORM\Column::UNIQUE)]
    #[Assert\Email(array("message" => "The email '{{ value }}' is not a valid email."))]
    private $email;

    #[Assert\Range(["min" => 120, "max" => 180, "minMessage" => "You must be at least {{ limit }}cm tall to enter"])]
    #[ORM\Column(ORM\Column::T_INTEGER)]
    protected $height;

    #[ORM\ManyToMany(Phonenumber::class)]
    #[
       ORM\JoinTable("users_phonenumbers"),
       ORM\JoinColumn("user_id", "id"),
       ORM\InverseJoinColumn("phonenumber_id", "id", ORM\JoinColumn::UNIQUE)
     ]
    private $phonenumbers;
}

PHP 8.1

Readonly properties

https://wiki.php.net/rfc/readonly_properties_v2
class Test {
  public readonly string $prop;

  public function __construct(string $prop) {
    $this->prop = $prop; // Initialized once in same scope
  }
}

$test = new Test("foobar");
var_dump($test->prop);
$test->prop = "foobar"; // Error

Enums

https://wiki.php.net/rfc/enumerations
enum Suit {
  case Hearts;
  case Diamonds;
  case Clubs;
  case Spades;
}

function pick_a_card(Suit $suit) { ... }
pick_a_card(Suit::Clubs); // ok
pick_a_card('Spades');    // error

Fibers

https://wiki.php.net/rfc/fibers
use React\EventLoop\LoopInterface;
use React\Promise\PromiseInterface;

function await(PromiseInterface $promise, LoopInterface $loop): mixed {
  $fiber = Fiber::this();
  $promise->done(
    fn(mixed $value) => $loop->futureTick(fn() => $fiber->resume($value)),
    fn(Throwable $reason) => $loop->futureTick(fn() => $fiber->throw($reason))
  );

  return Fiber::suspend();
}

Static Variable Inheritance

https://wiki.php.net/rfc/static_variable_inheritance
class A {
  public static function counter() {
    static $i = 0;
    return ++$i;
  }
}
class B extends A {}

echo A::counter();
echo A::counter();
echo B::counter();
echo B::counter();
// PHP 8.0 outputs 1212
// PHP 8.1 outputs 1234

Never Return Type

https://wiki.php.net/rfc/noreturn_type
function redirect(string $uri): never {
  header('Location: ' . $uri);
  exit();
}

redirect('/index.html');
echo "this will never be executed!";

Final class constants

https://wiki.php.net/rfc/final_class_const
class Foo {
    final public const X = "foo";
}

class Bar extends Foo {
    public const X = "bar";
}

// Fatal error: Bar::X cannot override final constant Foo::X

New in initializers

https://wiki.php.net/rfc/new_in_initializers
class Test {
  public function __construct(private Logger $logger = new NullLogger) {}
}

// instead of

class Test {
  private Logger $logger;

  public function __construct(?Logger $logger = null) {
        $this->logger = $logger ?? new NullLogger;
    }
}

First-class callable syntax

https://wiki.php.net/rfc/first_class_callable_syntax
$fn = strlen(...);
$fn = $this->method(...)
$fn = Foo::method(...);

// instead of

$fn = Closure::fromCallable('strlen');
$fn = Closure::fromCallable([$this, 'method']);
$fn = Closure::fromCallable([Foo::class, 'method']);

Pure intersection types

https://wiki.php.net/rfc/pure-intersection-types
class A {
  private Traversable&Countable $countableIterator;

  public function setIterator(Traversable&Countable $countableIterator): void {
    $this->countableIterator = $countableIterator;
  }

  public function getIterator(): Traversable&Countable {
    return $this->countableIterator;
  }
}

35+ years!

  • Bell Northern Research - Toronto
  • Northern Telecom - Toronto
  • Digital Media Networks - Toronto
  • NovAtel - Calgary
  • Nutec Informática - Porto Alegre, Brazil
  • University of Toronto IT - Toronto
  • Bell Global Solutions - Toronto
  • IBM - Raleigh, NC
  • Linuxcare - San Francisco
  • Yahoo! - Sunnyvale
  • WePay - Palo Alto
  • Etsy

Create more value than you capture. -Tim O'Reilly

Work on things that matter (to you)

¡Gracias!


http://talks.php.net/etsymex21

Interested in joining our team?

Please email your CV/resume to

mexico-hiring@etsy.com