Built the password reset flow end to end. Forgot-password form, tokenised email link, reset form. Tokens are 64-byte random, stored as SHA-256 hashes in the database, expire after one hour. Anti-enumeration is built in — the success message is the same whether the email exists or not.
Then the My Account page. Logged-in users can update name, email, avatar (JPEG/PNG/GIF/WebP, 2 MB cap), and change their password (current + new + confirm) on the same page. Added a requireLogin() guard and explicit null checks on $user to prevent fatal errors when a session user_id is stale.
Some sharp edges along the way. The reset URL originally embedded the user's email — the CI4 router rejects @ in URL segments with HTTP 400. Pulled the email out and let the controller look it up from the token. The contact throttler crashed on IPv6 addresses because PSR-6 cache keys cannot contain colons — switched the key to md5($ip).
SMTP was the rabbit hole. Started on port 465 with SMTPCrypto=ssl (implicit SSL via fsockopen('ssl://...')) — it fails under Apache on XAMPP because the SSL context is different. Switched to port 587 with SMTPCrypto=tls (STARTTLS via stream_socket_enable_crypto()) and raised the timeout from 5 s to 15 s. Emails send.
Also wrote a tiny dev helper: php spark email:test <addr> for on-demand SMTP smoke tests.