Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [8.0, 8.1, 8.2, 8.3, 8.4]
php-version: [8.2, 8.3, 8.4, 8.5]
steps:
- uses: actions/checkout@v2

- name: Set up PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: uopz, xdebug
extensions: xdebug
tools: composer, phpunit

- name: Install Dependencies
run: composer install --prefer-dist --no-progress --no-suggest
run: composer install --prefer-dist --no-progress

- name: Run PHPUnit Tests
run: XDEBUG_MODE=coverage ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --coverage-text
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ test/posthog.log
.phplint-cache
.idea
.phpunit.result.cache
.phpunit.cache
clover.xml
xdebug.log
.DS_Store
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# PostHog PHP

[![PHP Version](https://img.shields.io/packagist/php-v/posthog/posthog-php?logo=php)](https://packagist.org/packages/posthog/posthog-php)
[![CI](https://github.com/PostHog/posthog-php/actions/workflows/php.yml/badge.svg)](https://github.com/PostHog/posthog-php/actions/workflows/php.yml)

Please see the main [PostHog docs](https://posthog.com/docs).

Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-integration) details.
Expand Down
9 changes: 4 additions & 5 deletions composer.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we need to bump to version 4 in here or it's automated?

Copy link
Contributor Author

@vdekrijger vdekrijger Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should work out of the box once I follow the steps listed here: https://github.com/PostHog/posthog-php/blob/master/RELEASING.md!

There is nothing happening automatically, but I will obviously tag it with v4 😄 !

Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@
],
"require": {
"ext-json": "*",
"php": ">=8.0"
"php": ">=8.2",
"symfony/clock": "^6.2|^7.0"
},
"require-dev": {
"overtrue/phplint": "^3.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "^3.7",
"slope-it/clock-mock": "^0.4.0"
"phpunit/phpunit": "^11.0",
"squizlabs/php_codesniffer": "^3.7"
},
"autoload": {
"psr-4": {
Expand Down
5 changes: 3 additions & 2 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PostHog\Consumer\ForkCurl;
use PostHog\Consumer\LibCurl;
use PostHog\Consumer\Socket;
use Symfony\Component\Clock\Clock;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total PHP noob
(well, i ran a drupal site like 18 years ago)

is it necessary to go from standard library time to Symfony?

is there something that could trip us up here?
since the time of an event is so critical just worried about a silly mistake in the change (genuinely asking from a place of ignorance though :)))

Copy link
Contributor Author

@vdekrijger vdekrijger Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a sneaky one, the https://github.com/slope-it/clock-mock#mocked-functionsmethods library modifies the standard library and replaced the time() functionality with some custom logic. E.g. it intercepts the function at C level in order to be a "drop-in replacement" (context).

We needed to switch this to the Symfony clock in order to be able to manipulate it during our testing (same with the case in FeatureFlag.php), however I'm doing some testing to confirm that the behaviour is the same, as it's been a while since I've played with time based things in PHP 😄


const SIZE_LIMIT = 50_000;

Expand Down Expand Up @@ -829,7 +830,7 @@ private function formatTime($ts)
{
// time()
if (null == $ts || !$ts) {
$ts = time();
$ts = Clock::get()->now()->getTimestamp();
}
if (false !== filter_var($ts, FILTER_VALIDATE_INT)) {
return date("c", (int)$ts);
Expand All @@ -841,7 +842,7 @@ private function formatTime($ts)
return date("c", strtotime($ts));
}

return date("c");
return date("c", Clock::get()->now()->getTimestamp());
}

// fix for floatval casting in send.php
Expand Down
4 changes: 3 additions & 1 deletion lib/FeatureFlag.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace PostHog;

use Symfony\Component\Clock\Clock;

const LONG_SCALE = 0xfffffffffffffff;

class FeatureFlag
Expand Down Expand Up @@ -213,7 +215,7 @@ public static function matchPropertyGroup($propertyGroup, $propertyValues, $coho
public static function relativeDateParseForFeatureFlagMatching($value)
{
$regex = "/^-?(?<number>[0-9]+)(?<interval>[a-z])$/";
$parsedDt = new \DateTime("now", new \DateTimeZone("UTC"));
$parsedDt = \DateTime::createFromInterface(Clock::get()->now())->setTimezone(new \DateTimeZone("UTC"));
if (preg_match($regex, $value, $matches)) {
$number = intval($matches["number"]);

Expand Down
3 changes: 0 additions & 3 deletions lib/HttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,6 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders
$httpResponse = $this->executePost($ch, $includeEtag);
$responseCode = $httpResponse->getResponseCode();

//close connection
curl_close($ch);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Handle 304 Not Modified - this is a success, not an error
if ($responseCode === 304) {
if ($this->debug) {
Expand Down
24 changes: 9 additions & 15 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,36 +1,30 @@
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
backupGlobals="true"
backupStaticAttributes="false"
backupStaticProperties="false"
colors="true"
convertErrorsToExceptions="true"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explanation of the chnages:

  ┌─────────────────────────────┬────────────────────────┬────────────────────────┬─────────────────────────────────┐
  │           Change            │    Old (PHPUnit 9)     │    New (PHPUnit 11)    │             Reason              │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ Schema                      │ phpunit.xsd 9.3        │ phpunit.xsd 11.5       │ Updated to match PHPUnit 11     │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ backupStaticAttributes      │ backupStaticAttributes │ backupStaticProperties │ Renamed in PHPUnit 10           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ convertErrorsToExceptions   │ true                   │ removed                │ Now always true, option removed │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ convertNoticesToExceptions  │ true                   │ removed                │ Now always true, option removed │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ convertWarningsToExceptions │ true                   │ removed                │ Now always true, option removed │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ forceCoversAnnotation       │ false                  │ removed                │ Removed in PHPUnit 10           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ timeoutForSmallTests        │ 1                      │ removed                │ Removed in PHPUnit 10           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ timeoutForMediumTests       │ 10                     │ removed                │ Removed in PHPUnit 10           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ timeoutForLargeTests        │ 60                     │ removed                │ Removed in PHPUnit 10           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ verbose                     │ false                  │ removed                │ Now a CLI-only option           │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ cacheDirectory              │ none                   │ .phpunit.cache         │ Required in PHPUnit 10+         │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ processUncoveredFiles       │ processUncoveredFiles  │ includeUncoveredFiles  │ Renamed                         │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ <include> location          │ Inside <coverage>      │ Inside new <source>    │ Moved in PHPUnit 10             │
  ├─────────────────────────────┼────────────────────────┼────────────────────────┼─────────────────────────────────┤
  │ <logging/>                  │ Empty tag              │ removed                │ Was unused, removed             │
  └─────────────────────────────┴────────────────────────┴────────────────────────┴─────────────────────────────────┘
  Summary: PHPUnit 10 simplified the config by removing options that are now always-on or CLI-only, and reorganized where source directories are specified (moved from <coverage> to <source>). The changes are all structural to match PHPUnit 11's schema - no behavioral changes to how tests run.

convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
forceCoversAnnotation="false"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
stopOnIncomplete="false"
stopOnSkipped="false"
stopOnRisky="false"
timeoutForSmallTests="1"
timeoutForMediumTests="10"
timeoutForLargeTests="60"
verbose="false"
cacheDirectory=".phpunit.cache"
>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./lib</directory>
</include>
<coverage includeUncoveredFiles="true">
<report>
<text outputFile="php://stdout" showUncoveredFiles="true"/>
</report>
</coverage>
<source>
<include>
<directory suffix=".php">./lib</directory>
</include>
</source>
<testsuites>
<testsuite name="posthog-php">
<directory>test</directory>
</testsuite>
</testsuites>
<logging/>
</phpunit>
38 changes: 38 additions & 0 deletions test/ClockMockTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace PostHog\Test;

use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Clock\NativeClock;

/**
* Trait providing time mocking functionality for tests using Symfony Clock.
* This replaces the slope-it/clock-mock dependency which required the uopz extension.
*/
Comment on lines +9 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahhhhhh

ok, so the switch to symfony is for this mocking?

in which case, alongside the "is it correct/safe" question, is this (and i hate this word) idiomatic for PHP

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah traits are quite a common pattern within PHP, and they are frequently used to house "common / reusable methods" that span across a hierarchical / compositional object structure. + The added benefit here is that we cna have it act as a drop in replacement without changing much in the underlying function calls 😄 .

Here is a blog post of how Laravel does it (from 2023), which is one of the most popular PHP frameworks: https://laraveldaily.com/post/traits-laravel-eloquent-examples (or rather how spatie does it, but they are a very big contributor to Laravel).

trait ClockMockTrait
{
/**
* Execute a callback with a frozen date/time.
* This mimics the behavior of SlopeIt\ClockMock\ClockMock::executeAtFrozenDateTime()
*
* @param \DateTimeInterface $dateTime The date/time to freeze to
* @param callable $callback The callback to execute
* @return mixed The return value of the callback
*/
protected function executeAtFrozenDateTime(\DateTimeInterface $dateTime, callable $callback): mixed
{
$mockClock = new MockClock($dateTime instanceof \DateTimeImmutable
? $dateTime
: \DateTimeImmutable::createFromInterface($dateTime));

Clock::set($mockClock);

try {
return $callback();
} finally {
// Reset to real clock
Clock::set(new NativeClock());
}
}
}
20 changes: 10 additions & 10 deletions test/FeatureFlagErrorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
use PostHog\FeatureFlagError;
use PostHog\PostHog;
use PostHog\Test\Assets\MockedResponses;
use SlopeIt\ClockMock\ClockMock;

class FeatureFlagErrorTest extends TestCase
{
use ClockMockTrait;
public const FAKE_API_KEY = "random_key";

private $http_client;
Expand All @@ -40,7 +40,7 @@ public function setUp($flagsEndpointResponse = MockedResponses::FLAGS_RESPONSE,

public function testFlagMissingError()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->setUp(MockedResponses::FLAGS_RESPONSE, personalApiKey: null);

// Request a flag that doesn't exist in the response
Expand Down Expand Up @@ -70,7 +70,7 @@ public function testFlagMissingError()

public function testErrorsWhileComputingFlagsError()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a response with errorsWhileComputingFlags set to true
$responseWithErrors = array_merge(MockedResponses::FLAGS_RESPONSE, [
'errorsWhileComputingFlags' => true
Expand Down Expand Up @@ -103,7 +103,7 @@ public function testErrorsWhileComputingFlagsError()

public function testMultipleErrors()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a response with errorsWhileComputingFlags set to true
// and request a flag that doesn't exist
$responseWithErrors = array_merge(MockedResponses::FLAGS_RESPONSE, [
Expand Down Expand Up @@ -137,7 +137,7 @@ public function testMultipleErrors()

public function testNoErrorWhenFlagEvaluatesSuccessfully()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->setUp(MockedResponses::FLAGS_RESPONSE, personalApiKey: null);

// Request a flag that exists in the response
Expand All @@ -164,7 +164,7 @@ public function testNoErrorWhenFlagEvaluatesSuccessfully()

public function testUnknownErrorWhenExceptionThrown()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a mocked client that will throw an exception
$this->http_client = new class ("app.posthog.com") extends MockedHttpClient {
public function sendRequest(
Expand Down Expand Up @@ -245,7 +245,7 @@ public function testApiErrorMethod()

public function testTimeoutError()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a mocked client that simulates a timeout (responseCode=0, curlErrno=28)
$this->http_client = new MockedHttpClient(
"app.posthog.com",
Expand Down Expand Up @@ -291,7 +291,7 @@ public function testTimeoutError()

public function testConnectionError()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a mocked client that simulates a connection error (responseCode=0, curlErrno=6)
$this->http_client = new MockedHttpClient(
"app.posthog.com",
Expand Down Expand Up @@ -337,7 +337,7 @@ public function testConnectionError()

public function testApiError500()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a mocked client that simulates a 500 error
$this->http_client = new MockedHttpClient(
"app.posthog.com",
Expand Down Expand Up @@ -382,7 +382,7 @@ public function testApiError500()

public function testQuotaLimitedError()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
// Create a response with quotaLimited containing feature_flags
$quotaLimitedResponse = array_merge(MockedResponses::FLAGS_RESPONSE, [
'quotaLimited' => ['feature_flags']
Expand Down
18 changes: 9 additions & 9 deletions test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

use Exception;
use PHPUnit\Framework\TestCase;
use SlopeIt\ClockMock\ClockMock;
use PostHog\FeatureFlag;
use PostHog\Client;
use PostHog\PostHog;
Expand All @@ -17,6 +16,7 @@

class FeatureFlagLocalEvaluationTest extends TestCase
{
use ClockMockTrait;
protected const FAKE_API_KEY = "random_key";

protected Client $client;
Expand Down Expand Up @@ -604,7 +604,7 @@ public function testMatchPropertyDateOperators(): void

public function testMatchPropertyRelativeDateOperators(): void
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {

$prop_a = [
"key" => "key",
Expand Down Expand Up @@ -951,7 +951,7 @@ public function testRelativeDateParsingOverflow()
public function testRelativeDateParsingHours()
{

ClockMock::executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1h'), new \DateTime('2020-01-01T11:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2h'), new \DateTime('2020-01-01T10:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('24h'), new \DateTime('2019-12-31T12:01:20Z'));
Expand All @@ -965,7 +965,7 @@ public function testRelativeDateParsingHours()

public function testRelativeDateParsingDays()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1d'), new \DateTime('2019-12-31T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2d'), new \DateTime('2019-12-30T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('7d'), new \DateTime('2019-12-25T12:01:20Z'));
Expand All @@ -978,7 +978,7 @@ public function testRelativeDateParsingDays()

public function testRelativeDateParsingWeeks()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1w'), new \DateTime('2019-12-25T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2w'), new \DateTime('2019-12-18T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('4w'), new \DateTime('2019-12-04T12:01:20Z'));
Expand All @@ -991,7 +991,7 @@ public function testRelativeDateParsingWeeks()

public function testRelativeDateParsingMonths()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1m'), new \DateTime('2019-12-01T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2m'), new \DateTime('2019-11-01T12:01:20Z'));

Expand All @@ -1007,7 +1007,7 @@ public function testRelativeDateParsingMonths()
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('12m'), FeatureFlag::relativeDateParseForFeatureFlagMatching('1y'));
});

ClockMock::executeAtFrozenDateTime(new \DateTime('2020-04-03T00:00:00Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-04-03T00:00:00Z'), function () {
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1m'), new \DateTime('2020-03-03T00:00:00Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2m'), new \DateTime('2020-02-03T00:00:00Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('4m'), new \DateTime('2019-12-03T00:00:00Z'));
Expand All @@ -1021,7 +1021,7 @@ public function testRelativeDateParsingMonths()

public function testRelativeDateParsingYears()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2020-01-01T12:01:20Z'), function () {

self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('1y'), new \DateTime('2019-01-01T12:01:20Z'));
self::assertEquals(FeatureFlag::relativeDateParseForFeatureFlagMatching('2y'), new \DateTime('2018-01-01T12:01:20Z'));
Expand Down Expand Up @@ -1339,7 +1339,7 @@ public function testLoadFeatureFlagsWrongKey()

public function testSimpleFlag()
{
ClockMock::executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {
$this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () {

$this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_SIMPLE_REQUEST);
$this->client = new Client(
Expand Down
Loading