First PHP Projects

CLI Product Report

This first project turns ordinary PHP functions into a command-line program. Shell arguments are untrusted strings, standard output is for report data, standard error is for diagnostics, and the exit code tells automation whether the command succeeded.

bin/products-report.php
src/ProductReport.php

The command accepts a required draft or published status and an optional lines or csv format.

Keep Filtering Independent

Put the reusable rule in src/ProductReport.php:

PHP example
<?php

declare(strict_types=1);

function productsWithStatus(array $products, string $status): array
{
    if (!in_array($status, ['draft', 'published'], true)) {
        throw new InvalidArgumentException('Status must be draft or published.');
    }

    return array_values(array_filter(
        $products,
        static fn (array $product): bool => ($product['status'] ?? null) === $status,
    ));
}

This function knows nothing about $argv, terminal streams, CSV, or exit codes. A test can call it without simulating a terminal.

Define The CLI Contract

Put the boundary in bin/products-report.php:

PHP example
<?php

declare(strict_types=1);

// no-execute: requires the project file created above and CLI arguments.
require dirname(__DIR__) . '/src/ProductReport.php';

const EXIT_FAILURE = 1;
const EXIT_USAGE = 2;

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "This command must run from the CLI.\n");
    exit(EXIT_FAILURE);
}

$status = $argv[1] ?? null;
$format = $argv[2] ?? 'lines';

if ($status === null || !in_array($format, ['lines', 'csv'], true)) {
    fwrite(STDERR, "Usage: php bin/products-report.php <draft|published> [lines|csv]\n");
    exit(EXIT_USAGE);
}

$products = [
    ['name' => 'Notebook', 'status' => 'published'],
    ['name' => 'Desk, large', 'status' => 'published'],
    ['name' => 'Desk lamp', 'status' => 'draft'],
];

try {
    $matches = productsWithStatus($products, $status);
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, $exception->getMessage() . PHP_EOL);
    exit(EXIT_USAGE);
}

foreach ($matches as $product) {
    if ($format === 'csv') {
        $written = fputcsv(
            STDOUT,
            [(string) $product['name'], (string) $product['status']],
            ',',
            '"',
            '',
        );

        if ($written === false) {
            fwrite(STDERR, "Could not write the report.\n");
            exit(EXIT_FAILURE);
        }

        continue;
    }

    echo $product['name'] . PHP_EOL;
}

Use fputcsv() instead of concatenating fields. It quotes commas, quotes, and line breaks correctly. Passing the escape argument explicitly avoids PHP 8.5's deprecation for relying on its default.

Exit code 0 means success, including a valid report with no rows. Code 2 means invalid usage, and 1 means an operational output failure.

Run Every Path

php bin/products-report.php published
php bin/products-report.php published csv
php bin/products-report.php archived
echo $?
php bin/products-report.php
echo $?

CSV mode produces two valid fields even for Desk, large. Invalid or missing arguments write to standard error and exit with 2.

PHP Details To Notice

  • $argv[0] is the script name; user arguments begin at $argv[1].
  • PHP_SAPI prevents accidental use through a web server.
  • STDOUT and STDERR keep report data separate from diagnostics.
  • PHP_EOL uses the platform line ending for plain text.
  • exit() sets the status observed by a shell, CI job, or scheduler.

Practice

Practice: Build A Product Report Command

Create src/ProductReport.php and bin/products-report.php, then extend the guided command into a report safe for people and automation.

Requirements

  • Accept a required status and an optional lines or csv format.
  • Allow only draft and published statuses.
  • Keep filtering independent from $argv, terminal output, and exit().
  • Print one product name per line in lines mode.
  • Produce valid two-column CSV with fputcsv() in csv mode.
  • Pass the fputcsv() escape argument explicitly for PHP 8.5.
  • Write report data to standard output and diagnostics to standard error.
  • Exit with 0 on success, 2 for invalid usage, and 1 if output fails.
  • Show a usage message for missing or invalid arguments.

Use a product name containing a comma or quote. Run valid, empty-result, missing-argument, invalid-status, and invalid-format cases. Record the output stream and exit code for each case.

Show solution
PHP example
<?php

declare(strict_types=1);

// no-execute: requires the project file created in the lesson and CLI arguments.
require dirname(__DIR__) . '/src/ProductReport.php';

const EXIT_FAILURE = 1;
const EXIT_USAGE = 2;

if (PHP_SAPI !== 'cli') {
    fwrite(STDERR, "This command must run from the CLI.\n");
    exit(EXIT_FAILURE);
}

$status = $argv[1] ?? null;
$format = $argv[2] ?? 'lines';

if ($status === null || !in_array($format, ['lines', 'csv'], true)) {
    fwrite(STDERR, "Usage: php bin/products-report.php <draft|published> [lines|csv]\n");
    exit(EXIT_USAGE);
}

$products = [
    ['name' => 'Notebook', 'status' => 'published'],
    ['name' => 'Desk, large', 'status' => 'published'],
    ['name' => 'Desk lamp', 'status' => 'draft'],
];

try {
    $matches = productsWithStatus($products, $status);
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, $exception->getMessage() . PHP_EOL);
    exit(EXIT_USAGE);
}

foreach ($matches as $product) {
    if ($format === 'csv') {
        if (fputcsv(STDOUT, [$product['name'], $product['status']], ',', '"', '') === false) {
            fwrite(STDERR, "Could not write the report.\n");
            exit(EXIT_FAILURE);
        }

        continue;
    }

    echo $product['name'] . PHP_EOL;
}

Run:

php bin/products-report.php published
php bin/products-report.php published csv
php bin/products-report.php archived
echo $?
php bin/products-report.php published json
echo $?

The comma-containing name becomes "Desk, large",published, so a CSV reader sees two fields. Invalid commands write only to standard error and exit with 2.