First PHP Projects

CLI Product Report

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

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

Start With A Pure Filter

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 has no terminal dependency. A test can pass an array and compare the returned rows without replacing $argv, capturing output, or intercepting exit().

Read Arguments At The Boundary

In a CLI process, $argv[0] is the script name. User arguments start at $argv[1]:

PHP example
<?php

declare(strict_types=1);

// no-execute: fragment requires CLI arguments.
$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(2);
}

Missing status and unknown format are usage errors. They write a diagnostic to STDERR, not into the report stream, and exit with 2. The filter separately rejects an unknown status using the same usage outcome.

Format One Row Correctly

Plain lines and CSV have different encoding rules. Isolate that choice:

PHP example
<?php

declare(strict_types=1);

function writeProductRow(array $product, string $format): bool
{
    if ($format === 'csv') {
        return fputcsv(
            STDOUT,
            [(string) $product['name'], (string) $product['status']],
            ',',
            '"',
            '',
        ) !== false;
    }

    return fwrite(STDOUT, (string) $product['name'] . PHP_EOL) !== false;
}

Do not build CSV with string concatenation. fputcsv() quotes commas, quotes, and line breaks. PHP 8.5 also expects the escape argument to be supplied explicitly; the empty string selects standards-compatible escaping.

Compose The Command Last

The entry script now has a short job:

PHP example
// Partial: follows argument parsing and the project require.
try {
    $matches = productsWithStatus($products, $status);
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, $exception->getMessage() . PHP_EOL);
    exit(2);
}

foreach ($matches as $product) {
    if (!writeProductRow($product, $format)) {
        fwrite(STDERR, "Could not write the report.\n");
        exit(1);
    }
}

Reaching the end gives the shell exit code 0. A valid filter with no matching rows is still successful; an output failure exits with 1; invalid usage exits with 2.

Run The Contract

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

Use a product named Desk, large to prove CSV mode still emits exactly two fields. Redirect standard output and standard error separately to verify diagnostics never corrupt report data.

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

Keep productsWithStatus() and writeProductRow() in src/ProductReport.php. Build bin/products-report.php in three ordered sections.

Bootstrap And Parse

PHP example
<?php

declare(strict_types=1);

// no-execute: first section of a CLI entry file; later sections complete it.
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';

Reject Invalid Usage

Append this section to the same file:

PHP example
// Partial: continues bin/products-report.php.
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'],
];

Filter And Write

Finish the file with the orchestration:

PHP example
// Partial: final section of bin/products-report.php.
try {
    $matches = productsWithStatus($products, $status);
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, $exception->getMessage() . PHP_EOL);
    exit(EXIT_USAGE);
}

foreach ($matches as $product) {
    if (!writeProductRow($product, $format)) {
        fwrite(STDERR, "Could not write the report.\n");
        exit(EXIT_FAILURE);
    }
}

Run normal, CSV, missing-status, invalid-status, and invalid-format cases. CSV output for the comma-containing name is "Desk, large",published, and invalid commands write only to standard error with exit code 2.