Project: Data-Cleaning Script
A data-cleaning script takes messy input and turns it into a predictable shape. In real PHP work, this comes up when importing CSV files, processing form exports, tidying old database records, preparing data for an API, or fixing inconsistent values before a migration.
This mini-project keeps the input inside the script so you can focus on the language work. The important skills are trimming strings, normalising case, validating required values, looping through rows, returning clean arrays, and reporting what changed.
Start with messy rows
Raw data is rarely perfect. A customer export might contain extra spaces, mixed casing, empty names, and email addresses with capital letters.
<?php
declare(strict_types=1);
$rows = [
['name' => ' ada lovelace ', 'email' => ' ADA@EXAMPLE.COM '],
['name' => 'grace HOPPER', 'email' => 'grace@example.com'],
['name' => ' ', 'email' => 'missing@example.com'],
];
Each row is an associative array. The script should clean the good rows and skip the row that does not have a usable name.
Clean one value first
Do not start by writing a large loop. Write a small function that cleans one piece of data.
<?php
declare(strict_types=1);
function cleanName(string $name): string
{
$name = trim($name);
$name = strtolower($name);
return ucwords($name);
}
echo cleanName(' ada lovelace ');
// Prints:
// Ada Lovelace
trim() removes whitespace from the beginning and end. strtolower() gives you a predictable base. ucwords() then capitalises each word.
Clean email addresses differently
Not all fields use the same rule. Names might be title-cased, but email addresses are usually lower-cased and trimmed.
<?php
declare(strict_types=1);
function cleanEmail(string $email): string
{
return strtolower(trim($email));
}
echo cleanEmail(' ADA@EXAMPLE.COM ');
// Prints:
// ada@example.com
Small named functions make these rules easy to review. A future developer can change name cleaning without accidentally changing email cleaning.
Decide what counts as valid
Cleaning is not the same as accepting everything. If a required value becomes empty after trimming, the row should not be treated as valid.
<?php
declare(strict_types=1);
function hasRequiredName(array $row): bool
{
return trim($row['name'] ?? '') !== '';
}
var_dump(hasRequiredName(['name' => ' ']));
// Prints:
// bool(false)
The ?? operator protects the code when a key is missing. A missing name and a blank name both fail the check.
Return cleaned rows
The main cleaning function should accept a list of rows and return a new list. That keeps the function easy to use and avoids surprising changes to the original data.
<?php
declare(strict_types=1);
function cleanName(string $name): string
{
return ucwords(strtolower(trim($name)));
}
function cleanEmail(string $email): string
{
return strtolower(trim($email));
}
function cleanCustomerRows(array $rows): array
{
$cleaned = [];
foreach ($rows as $row) {
if (trim($row['name'] ?? '') === '') {
continue;
}
$cleaned[] = [
'name' => cleanName($row['name']),
'email' => cleanEmail($row['email'] ?? ''),
];
}
return $cleaned;
}
$rows = [
['name' => ' ada lovelace ', 'email' => ' ADA@EXAMPLE.COM '],
['name' => ' ', 'email' => 'missing@example.com'],
];
print_r(cleanCustomerRows($rows));
// Prints:
// Array
// (
// [0] => Array
// (
// [name] => Ada Lovelace
// [email] => ada@example.com
// )
// )
continue skips invalid rows. The script does not crash just because one row in the import is unusable.
Count what happened
A practical cleaning script should report what it did. Counts make the result easier to check.
<?php
declare(strict_types=1);
$totalRows = 3;
$cleanedRows = 2;
$skippedRows = $totalRows - $cleanedRows;
echo 'Read: ' . $totalRows . PHP_EOL;
echo 'Cleaned: ' . $cleanedRows . PHP_EOL;
echo 'Skipped: ' . $skippedRows . PHP_EOL;
// Prints:
// Read: 3
// Cleaned: 2
// Skipped: 1
Counts are useful in command-line scripts because they make manual verification quick. If a file has 500 rows and the script says it cleaned 40, you know something is wrong.
Keep validation clear
For a beginner project, skipping invalid rows is acceptable if the script reports the skip. In more serious imports, you might collect row numbers and error messages.
<?php
declare(strict_types=1);
function validateCustomerRow(array $row): ?string
{
if (trim($row['name'] ?? '') === '') {
return 'Name is required.';
}
if (trim($row['email'] ?? '') === '') {
return 'Email is required.';
}
return null;
}
echo validateCustomerRow(['name' => '', 'email' => 'a@example.com']);
// Prints:
// Name is required.
Returning null for "no error" is a common simple pattern. Later, larger applications may use exceptions, validation objects, or framework validators.
Treat name casing as a sample policy
strtolower() and ucwords() are byte-oriented and the transformation can damage legitimate names such as McDonald, O'Neill, acronyms, particles, and non-ASCII writing systems. Real systems should not silently rewrite personal names without an agreed data policy.
For this exercise, the source values are controlled ASCII examples and title casing is the stated output rule. In a real import, trimming surrounding whitespace may be safe while preserving the person's chosen casing is often better. Unicode-aware case changes require the Multibyte String extension and still do not solve every naming convention.
Document whether cleaning means normalising, validating, or merely flagging a value for review.
Validate PHP types before string operations
Missing-key fallbacks do not protect against a present key containing an array, integer, or null. Build a validator that checks shape explicitly:
<?php
declare(strict_types=1);
function validateCustomerRow(array $row): ?string
{
$name = $row['name'] ?? null;
$email = $row['email'] ?? null;
if (!is_string($name) || trim($name) === '') {
return 'Name is required.';
}
if (!is_string($email) || trim($email) === '') {
return 'Email is required.';
}
$email = strtolower(trim($email));
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return 'Email is invalid.';
}
return null;
}
The nullable string result means either one validation message or no error. This is suitable for a small cleaning script that expects invalid rows and continues.
FILTER_VALIDATE_EMAIL is a practical syntax check, not proof that an address exists, accepts mail, or belongs to a customer.
Preserve raw input for diagnosis
Do not overwrite the only copy of imported data before you know the cleaning succeeded. Keep raw rows and create a separate accepted list:
$rawRows = $rows;
$cleanedRows = [];
$rejections = [];
For rejected data, record a row number and reason rather than dumping the complete row. Imports can contain personal or sensitive information that should not be copied casually into logs.
$rejections[] = [
'row_number' => $index + 1,
'reason' => $error,
];
Human-facing row numbers usually begin at one, while PHP list indexes begin at zero. Make that conversion explicit.
Reject duplicate identifiers deliberately
If email is the import's unique identifier, detect duplicates after normalisation. ADA@EXAMPLE.COM and ada@example.com should compare as the same address under the exercise policy.
<?php
$seenEmails = [];
$rejections = [];
foreach ($rows as $index => $row) {
$email = cleanEmail($row['email']);
if (isset($seenEmails[$email])) {
$rejections[] = [
'row_number' => $index + 1,
'reason' => 'Duplicate email.',
];
continue;
}
$seenEmails[$email] = true;
}
A keyed lookup makes membership checks direct. Decide whether the first row wins, the last row wins, or duplicates stop the import. This example keeps the first accepted row and rejects later duplicates.
Build one auditable cleaning result
A useful cleaning function can return accepted rows and rejection metadata together:
<?php
declare(strict_types=1);
function cleanCustomerRows(array $rows): array
{
$cleanedRows = [];
$rejections = [];
$seenEmails = [];
foreach ($rows as $index => $row) {
if (!is_array($row)) {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Row must be an array.'];
continue;
}
$error = validateCustomerRow($row);
if ($error !== null) {
$rejections[] = ['row_number' => $index + 1, 'reason' => $error];
continue;
}
$email = cleanEmail($row['email']);
if (isset($seenEmails[$email])) {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Duplicate email.'];
continue;
}
$seenEmails[$email] = true;
$cleanedRows[] = [
'name' => cleanName($row['name']),
'email' => $email,
];
}
return [
'cleaned' => $cleanedRows,
'rejections' => $rejections,
];
}
The caller can write accepted data to one destination and report rejection counts separately. The cleaner returns data and performs no output.
Check cleaning invariants
After every run:
- cleaned count plus rejected count equals input count;
- every cleaned row has the same documented keys;
- every cleaned email passes validation;
- no cleaned email appears twice under the chosen comparison policy;
- raw input remains available for controlled diagnosis;
- running already cleaned sample data through the cleaner again produces the same cleaned values.
The final property is idempotence. A cleaning rule that changes its own output on every run can corrupt repeated imports.
Test missing keys, wrong PHP types, whitespace-only values, malformed email syntax, duplicate addresses with different case, and valid rows after invalid rows. One successful example does not prove an import pipeline.
What a good solution includes
A good data-cleaning script should have small functions for each cleaning rule, a loop that handles every row, clear validation for required values, and a final report showing how many rows were read, cleaned, and skipped.
Before moving on, make sure you can explain why trim(), case normalisation, missing-key checks, and skipped-row counts matter in a real import or maintenance script.
Practice
Task: Clean Customer Names
Clean these values:
$names = [
' ada lovelace ',
'grace HOPPER',
' alan TURING',
];
Print the three cleaned names. Then pass Ada Lovelace through the function a second time and print Idempotent: yes when the result is unchanged.
State in one sentence why this casing policy should not be applied blindly to all real personal names.
Show solution
<?php
declare(strict_types=1);
function cleanName(string $name): string
{
return ucwords(strtolower(trim($name)));
}
$names = [
' ada lovelace ',
'grace HOPPER',
' alan TURING',
];
foreach ($names as $name) {
echo cleanName($name), PHP_EOL;
}
$idempotent = cleanName(cleanName(' ada lovelace ')) === 'Ada Lovelace';
echo 'Idempotent: ' . ($idempotent ? 'yes' : 'no') . PHP_EOL;
Explanation
The operations run in a stable order and produce the same result when repeated for these controlled values.
Real personal names use varied casing and writing systems, so an import policy should usually preserve chosen spelling or apply a specifically agreed Unicode-aware rule rather than this simplified transformation.
Task: Predict Cleaning Counts
<?php
declare(strict_types=1);
function hasRequiredName(array $row): bool
{
$name = $row['name'] ?? null;
return is_string($name) && trim($name) !== '';
}
$rows = [
['name' => ' Ada '],
['name' => ' '],
[],
['name' => 42],
['name' => 'Grace'],
];
$cleaned = 0;
$skipped = 0;
foreach ($rows as $row) {
if (!hasRequiredName($row)) {
$skipped++;
continue;
}
$cleaned++;
}
echo "Read: " . count($rows) . "\n";
echo "Cleaned: $cleaned\n";
echo "Skipped: $skipped\n";
Explain why the integer name is skipped without causing a TypeError, and verify that cleaned plus skipped equals read.
Show solution
Read: 5
Cleaned: 2
Skipped: 3
Explanation
Ada and Grace have non-empty strings after trimming. The whitespace-only string, missing key, and integer value fail the condition.
The && operator short-circuits. When is_string($name) is false for integer 42, PHP does not evaluate trim($name), so no string-parameter TypeError occurs.
The invariant holds: 2 + 3 = 5, accounting for every input row exactly once.
Task: Return A Cleaning Report
[
'cleaned' => [...],
'rejections' => [...],
]
For each row, require string name and email, reject empty values, lowercase and trim email, validate it with FILTER_VALIDATE_EMAIL, and reject later duplicate normalized emails. Clean accepted names with the controlled ASCII cleanName() policy.
Use rows for Ada, a blank name, Grace, duplicate uppercase ADA@EXAMPLE.COM, and an invalid email. Print accepted customers, then Cleaned: 2 and Rejected: 3.
The cleaning function must not print or mutate the source list.
Show solution
<?php
declare(strict_types=1);
function cleanName(string $name): string
{
return ucwords(strtolower(trim($name)));
}
function cleanCustomerRows(array $rows): array
{
$cleaned = [];
$rejections = [];
$seenEmails = [];
foreach ($rows as $index => $row) {
$name = $row['name'] ?? null;
$email = $row['email'] ?? null;
if (!is_string($name) || trim($name) === '') {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Invalid name'];
continue;
}
if (!is_string($email)) {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Invalid email'];
continue;
}
$email = strtolower(trim($email));
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Invalid email'];
continue;
}
if (isset($seenEmails[$email])) {
$rejections[] = ['row_number' => $index + 1, 'reason' => 'Duplicate email'];
continue;
}
$seenEmails[$email] = true;
$cleaned[] = ['name' => cleanName($name), 'email' => $email];
}
return ['cleaned' => $cleaned, 'rejections' => $rejections];
}
$rows = [
['name' => ' ada lovelace ', 'email' => ' ada@example.com '],
['name' => ' ', 'email' => 'blank@example.com'],
['name' => 'grace HOPPER', 'email' => 'GRACE@example.com'],
['name' => 'Another Ada', 'email' => 'ADA@EXAMPLE.COM'],
['name' => 'Invalid', 'email' => 'not-an-email'],
];
$result = cleanCustomerRows($rows);
foreach ($result['cleaned'] as $customer) {
echo $customer['name'] . ' <' . $customer['email'] . '>' . PHP_EOL;
}
echo 'Cleaned: ' . count($result['cleaned']) . PHP_EOL;
echo 'Rejected: ' . count($result['rejections']) . PHP_EOL;
Explanation
The function builds new accepted and rejected collections. Lowercasing occurs before duplicate lookup, so the later uppercase Ada address conflicts with the first accepted row.
Every input appears in exactly one result list, and the caller controls reporting. The original $rows array is never reassigned or modified.