Dev.to · 24 min read

Welcome back to PHP

Welcome back to PHP

I left programming in PHP when isset() was a lifestyle, array() had just become [], and types lived in docblocks where the runtime couldn't see them. The language you're returning to grew toward the exact things that pulled me to Ruby and Go: real signatures, immutability you can enforce, pattern matching, values that can't lie about what they are. Almost everything below follows one theme — declare your intent in the code, and let the engine enforce it — which means a huge amount of the defensive scaffolding in my old projects simply gets deleted, not rewritten. Part One: Null stops being a minefield Half of any 5.6 codebase is isset() ceremony. Three operators killed most of it. 1. Null coalescing — ?? // The old ritual $page = isset($_GET['page']) ? (int) $_GET['page'] : 1; // Now $page = (int) ($_GET['page'] ?? 1); // Walks arbitrarily deep without a single notice $city = $order['shipping']['address']['city'] ?? 'Unknown'; The win isn't character count — it's that ?? has isset() semantics baked in. Any part of the left side can be missing at any depth and you get the fallback, silently. That makes it different from ?:, which you may have picked up elsewhere: the elvis asks "is this truthy" and still complains when the variable doesn't exist; ?? asks "is this null or absent." That distinction is the one trap worth internalizing. '' ?? 'Guest' gives you the empty string, because an empty string isn't null. If old code leaned on empty()-style checks where 0 and '' should also fall through to the default, ?? is not a drop-in replacement — and the resulting bug is a quiet one: a blank form field sails through where "Guest" used to appear, and nothing errors. Rule of thumb: ?? means "was this provided at all," ?: means "is this something usable." 2. Null coalescing assignment — ??= // Hydrating a config with defaults $options['timeout'] ??= 30; $options['retries'] ??= 3; $options['base_url'] ??= 'https://api.example.com'; The write-back form: assign only if the target is null or unset. It inherits the same semantics — it will not clobber an explicit 0 or '', which for config defaults is usually exactly what you want (someone who deliberately set 'timeout' => 0 shouldn't lose it). One subtle trap when you use it for memoization — $this->cache[$key] ??= $this->compute($key); is a lovely one-line compute-once pattern, unless compute() can legitimately return null. A null result never sticks, so the expensive call re-runs on every access, and your "cache" becomes a very polite performance bug. 3. Nullsafe chaining — ?-> $street = $user?->profile?->address?->street ?? '(no address on file)'; Ruby's &., PHP spelling. The moment any link is null, the whole expression yields null — and pairs with ?? to land on a default. This is the fix to your original example, by the way: it's strictly for object access, so arrays keep using ??. Two behaviors deserve respect. First, the short-circuit is total: once a link is null, nothing to the right evaluates — including method calls and even their argument expressions. $logger?->log(buildExpensiveReport()) skips building the report entirely when the logger is null. Usually great; but if you were counting on a side effect in that chain, it silently never happened, and no error will ever tell you. Second, you can't assign through it — $user?->profile?->name = 'x' won't compile, deliberately. And the staff-engineer note: if you find yourself four ?-> deep on a regular basis, the operator is treating a symptom. That much optionality usually means the object model is lying about what's actually required. Use it for honest maybe-relationships; don't use it to paper over "everything might be null" design. Part Two: The type system grew teeth This is the section that changes how you debug. Bad data now explodes at the boundary where it enters, with the exact name of what's wrong — not three layers down where it finally gets used. 4. Real scalar types and strict_types declare(strict_types=1); function priceWithTax(float $price, float $rate): float { return $price * (1 + $rate); } priceWithTax(100.0, 0.08); // 108.0 priceWithTax(100, 0.08); // fine — int widens to float, deliberately priceWithTax('100', 0.08); // TypeError, immediately, with a filename and line Two-part story. Type declarations alone give you coercive mode — '100' gets quietly converted, which is barely better than nothing. The declare(strict_types=1) line at the top of a file is what makes it real: conversions become TypeErrors, with the single sane exception that ints are accepted where floats are expected. The mechanic that matters for your situation: strict_types is per-file, and it governs calls written in that file — not the file where the function lives. Which means you can rehab a legacy codebase incrementally: every new file declares strict and gets full enforcement, while ten-year-old files keep their loose behavior against the very same functions. No flag day required. That design choice is for you, specifically. For the Go-shaped part of your brain: an untyped parameter doesn't error, it just means "anything." The engine won't force annotations — that discipline is on you, plus tooling I'll name at the end. 5. Typed properties class Invoice { public int $number; public string $currency = 'USD'; public ?DateTimeImmutable $paidAt = null; } $inv = new Invoice(); echo $inv->number; // Error: Typed property Invoice::$number must not be accessed before initialization Properties carry types now, and with that comes a genuinely new state: uninitialized, which is not the same as null. Read a typed property before anything assigned it and you get a hard Error naming the exact property — not the old behavior where a forgotten assignment became a silent null and surfaced as a mystery in a completely different file. This is good news wearing scary clothes. Two rules keep it painless: nullable is always explicit (?DateTimeImmutable — a plain string can never hold null, even accidentally), and any property without a sensible default should be assigned on every constructor path. That second rule is why the constructor style in Part Five became the idiom — declare and initialize in one motion, and the uninitialized state becomes unreachable. 6. Union types — and combining interfaces function find(int|string $id): Employee|null { // ids arrive as ints from code, strings from routes — describe reality } function drain((Countable&Traversable)|array $items): void { // an array, OR an object that is BOTH countable and iterable } Signatures can now say "one of these" with |, and for objects, "all of these at once" with & — an intersection means the argument must implement every listed interface (scalars can't intersect; nothing is both int and string). Mix the two and PHP insists on the parenthesized or-of-ands shape you see above: A&(B|C) won't parse — distribute it yourself to (A&B)|(A&C). Traps: null is never implied. int|string rejects null; you must write int|string|null, and the ?Foo shorthand only works on a single type, never a union. And the judgment call: a union at an internal API is sometimes a smell. int|string $id at a boundary describes messy reality honestly; the same union three layers deep usually means a decision is being deferred — often there's a value object trying to be born. Use unions to describe truth at the edges, not to avoid establishing it inside. 7. never function abort(int $status, string $message): never { http_response_code($status); echo $message; exit; } function unreachable(mixed $value): never { throw new LogicException('Unhandled: ' . var_export($value, true)); } Not void. void means "returns, carrying nothing." never means control does not come back — the function throws or exits, full stop, and the engine enforces it (a never function that actually returns is a fatal error). The daily value is what it tells readers and static analyzers: everything after abort(...) is provably dead, so guard clauses stop generating "but what if execution continues past this" noise. It's the signature-level version of what your Ruby raise-only helpers always meant but could never promise. Part Three: Functions shed their weight 8. Arrow functions $rate = 0.08; // Then: the `use` tax on every closure $withTax = array_map(function ($p) use ($rate) { return $p * (1 + $rate); }, $prices); // Now: automatic capture $withTax = array_map(fn($p) => $p * (1 + $rate), $prices); fn captures the enclosing scope automatically — no more use lists that had to be maintained by hand every time the body changed. Two constraints define its shape. It's a single expression, no statement body — the moment you need two statements, use a full closure rather than contorting one expression to fake it. And capture is by value: this is where Ruby muscle memory bites, because a Ruby block mutating an outer local works, while an arrow function silently mutates a private copy. No warning — the outer variable just never changes. If you genuinely need write-back, that's a full closure with use (&$x), and also a moment to ask why a callback is mutating its environment. 9. First-class callable syntax $upper = strtoupper(...); $send = $mailer->send(...); $parse = JsonParser::parse(...); $names = array_map(strtoupper(...), $names); usort($users, $this->compareByTenure(...)); Roughly Ruby's &:method, but done right: func(...) produces a real Closure, bound where you write it. The old ways — 'strtoupper' strings, [$this, 'method'] arrays — were invisible to every tool you own: rename-refactors missed them, editors couldn't jump to them, typos survived until runtime. The new form is analyzed like the call it resembles: arity, types, and existence all checked. Better still, visibility is resolved where the callable is created — $this->somePrivateMethod(...) made inside the class remains callable after you hand it out, which the string forms fumbled. One boundary: (...) is syntax, not a value. $name = 'strtoupper'; $cb = $name(...); doesn't do what it looks like — genuinely runtime-dynamic dispatch still uses the old forms. For everything statically known, which is nearly everything, use this. 10. The pipe operator — |> The one that caught your eye, and rightly. Data flows left to right, each stage a callable receiving the previous result: $slug = ' Modern PHP: A Field Guide! ' |> trim(...) |> strtolower(...) |> fn($s) => preg_replace('/[^a-z0-9]+/', '-', $s) |> fn($s) => trim($s, '-'); // "modern-php-a-field-guide" Written as nested calls, that transformation reads inside-out and executes bottom-up; the pipe reads in execution order, the way Elixir and F# people have been smug about for years. Mechanics worth knowing: the right side must be a callable expression — hence trim(...) rather than bare trim — and the value arrives as the callable's argument, so any function that needs the value somewhere other than its first slot gets a little fn adapter, exactly like the preg_replace stages above. There's no placeholder syntax; the arrow function is the adapter. It also compiles down to the same nested calls you'd have written — zero runtime cost, purely a gift to the reader. Judgment: below three stages it's ceremony; at three-plus transformations it earns its keep. And this is the newest thing in this entire guide — the one place I'll tell you to check your deployment target before falling in love. Part Four: Control flow that tells the truth 11. match $label = match ($status) { 200, 201, 204 => 'Success', 301, 302 => 'Redirect', 404 => 'Not Found', default => throw new DomainException("Unmapped status: $status"), }; // The condition form — replaces elseif ladders $tier = match (true) { $spend >= 10_000 => 'platinum', $spend >= 1_000 => 'gold', default => 'standard', }; switch with every historical mistake removed. It's an expression — it returns a value, so the assign-in-every-branch dance is gone (and notice throw works as an expression now too, which is why it can live in an arm). Comparison is strict identity: match ('1') will not hit a 1 => arm. No fallthrough exists, so no break exists; commas group multiple matches. The unsung hero is exhaustiveness: no arm matches and no default present? UnhandledMatchError, thrown loudly — where switch shrugged and did nothing. Leave the default off on purpose when the input is an enum, and adding a new case later makes every non-exhaustive match in the codebase announce itself. That's Go's handle-every-case discipline, enforced. Two notes. Arms are single expressions — when one wants five lines of logic, extract a method; match is a router, not a residence. And a welcome-back gift about comparison generally: the loose-comparison horror you remember, where 0 == "abc" was true, got fixed — non-numeric strings no longer collapse to zero. Some of your era's defensive === paranoia is now just... how == behaves. 12. The spaceship — (properly, this time) The scar tissue first. Every 5.6 codebase contains a dozen of these: usort($employees, function ($a, $b) { if ($a->lastName == $b->lastName) { if ($a->firstName == $b->firstName) { return 0; } return ($a->firstName < $b->firstName) ? -1 : 1; } return ($a->lastName < $b->lastName) ? -1 : 1; }); Nine lines to say "sort by last name, then first." Get one -1/1 backwards — everyone has — and you ship a reversed sort that no error will ever catch. is that three-way comparison: it evaluates to negative, zero, or positive, exactly the contract usort wants, which is the only reason it exists. Ruby folks know it as the operator Comparable is built on — same symbol, same job, same "spaceship" nickname. The single-key case is the appetizer: usort($employees, fn($a, $b) => $a->hiredAt $b->hiredAt); The multi-key idioms are the meal. First, the elegant one — pack your sort keys into arrays, because arrays compare element by element: usort($employees, fn($a, $b) => [$a->lastName, $a->firstName] [$b->lastName, $b->firstName] ); Second, the flexible one — chain with ?:. A tie yields 0, zero is falsy, so the elvis falls through to the tiebreaker; and you can flip direction per key by swapping operands: // Salary descending, then name ascending usort($employees, fn($a, $b) => $b->salary $a->salary ?: $a->lastName $b->lastName ); Nine defensive lines became one declarative one, and the backwards-sort bug class is gone. Traps: it inherits PHP's ordinary comparison semantics, so mixed-type operands compare with all the enthusiasm of < — keep both sides the same type. Descending order is swap the operands, as above; negating the result works but reads like a puzzle. And resist deploying it outside comparator contexts as a clever three-way branch — in a sort callback it's idiomatic, anywhere else it's a riddle. Part Five: Classes without the ceremony This section will delete more lines from your old projects than everything else combined. The arc: state intent in the declaration, and stop writing methods whose only job was to compensate for the language. 13. Constructor property promotion // Then: every field's name written four times class Customer { private $name; private $email; public function __construct($name, $email) { $this->name = $name; $this->email = $email; } } // Now class Customer { public function __construct( private string $name, private string $email, ) {} } A visibility keyword on a constructor parameter declares the property, types it, and assigns it — one motion. (Trailing commas in parameter lists are legal now too; small mercy, big diffs.) You can mix promoted and ordinary parameters freely, and the constructor body still runs after assignment, so validation goes there and $this->email is already populated when it does. The one caution is aesthetic, and real: promotion makes it cheap to grow constructors, and a signature with nine promoted, defaulted, attributed parameters becomes a wall. When construction has genuine logic, a static named constructor (Customer::fromSignup($request)) calling a lean promoted constructor beats cramming intelligence into defaults. Promotion removes boilerplate; it doesn't remove the need for design. 14. Named arguments // You wrote this in 2014, and the fifth argument haunted code review: setcookie('session', $token, time() + 3600, '/', '', true, true); // Now the call site explains itself, and skips what it doesn't care about: setcookie('session', $token, expires_or_options: time() + 3600, path: '/', secure: true, httponly: true, ); Name any parameter at the call site, skip defaults you don't care about, and order stops mattering once you go named. Mystery booleans die; code review reads itself; functions with four optional parameters stop needing the null, null, null, true incantation. The gotcha has real teeth: parameter names are now public API. Rename $role to $userRole in a published method and every caller using role: breaks — a compatibility surface that did not exist in your era, and one library authors now genuinely manage. If code leaves your team, parameter names deserve the same reverence as method names. Mechanically: positional arguments must precede named ones in a call; once you've gone named, there's no returning to positional. 15. readonly properties final class Money { public function __construct( public readonly int $amount, public readonly string $currency, ) {} public function add(Money $other): self { if ($other->currency !== $this->currency) { throw new DomainException('Currency mismatch'); } return new self($this->amount + $other->amount, $this->currency); } } $price = new Money(4500, 'USD'); $price->amount = 1; // Error: Cannot modify readonly property Money::$amount Written once, from inside the declaring class — immutable to the world thereafter. This deletes an entire pattern: the private property plus getter-with-no-setter, which existed only to grant read access while blocking writes. public readonly is that, minus both methods. Your Ruby value-object instincts finally have a native home, and notice the shape of add() — operations return new instances, they don't mutate. The language now holds that promise for you. Three traps. A plain readonly declaration can't carry a default (public readonly string $currency = 'USD'; as a class-body line is illegal — a default would be the one allowed write, making it a constant) — but the promoted-parameter form in the example is fine, because there it's a parameter default. Readonly is shallow: a readonly property holding a mutable object doesn't freeze the object — $invoice->createdAt->modify('+1 day') still mutates if createdAt is a DateTime. Give readonly properties immutable types (DateTimeImmutable, other readonly objects) if you mean it. And arrays: $obj->items[] = $x on a readonly array property errors — appending is writing. 16. Readonly classes — and constants that grew up final readonly class Coordinates { public function __construct( public float $lat, public float $lon, ) {} } One keyword, every property readonly and required to be typed — the whole-class version of the previous section, ideal for DTOs and value objects where "one mutable field" would be a bug anyway. Children of a readonly class must themselves be readonly; you can't un-promise in a subclass. Need a single mutable property? Then it isn't a readonly class — drop to per-property. A readonly class also flatly refuses dynamic properties — and here's a bigger era-shift you should absorb now: the 5.6 behavior where $obj->whatever = 'x' silently materialized a property on any object is deprecated across the whole language. Assignments to undeclared properties warn today and are on their way out entirely. A generation of typo-shaped bugs, retired. Constants matured on the same theme: they can be typed (public const string VERSION = '2.1'; — a subclass can't redefine it as an int) and marked final (a subclass can't redefine it at all). In your day, any child could silently override any constant. Both holes now close on request. 17. Clone-with The missing half of readonly. Immutable objects live by the "wither" pattern — copy with one field changed — and until recently that meant get_object_vars() gymnastics that shattered whenever constructor args didn't map one-to-one to properties. Now: final readonly class Invoice { public function __construct( public string $number, public string $status, public int $totalCents, ) {} public function paid(): self { return clone($this, ['status' => 'paid']); } } $sent = new Invoice('INV-1042', 'sent', 45000); $paid = $sent->paid(); echo $sent->status; // 'sent' — untouched echo $paid->status; // 'paid' clone($obj, [...]) overrides properties during the clone — the one moment a readonly property permits a write. Note that clone grew a function-call form to make this possible: bare clone $obj still works as always, but the two-argument version needs the parentheses. The rules have teeth in the right places. Overrides are checked against normal visibility from the calling scope — outside code can't clone-with its way past your private or readonly protections, which is exactly why the idiom remains a small method on the class (paid(), withStatus()); clone-with deletes the method's boilerplate, not its gatekeeping. The overrides go through property hooks and __set like ordinary assignments, so validation still fires. And it's still a shallow clone — nested objects stay shared references unless __clone() steps in. Same rule as ever, now with better ergonomics around it. 18. Property hooks and asymmetric visibility The finale of the class story — the getter/setter farm, and the __get/__set magic swamp, both retired. class User { public string $email { set(string $value) { $clean = strtolower(trim($value)); if (!filter_var($clean, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException("Invalid email: $value"); } $this->email = $clean; } } public string $displayName { get => ucfirst($this->first) . ' ' . ucfirst($this->last); } public function __construct( private string $first, private string $last, string $email, ) { $this->email = $email; // runs the hook — invariants hold from birth } } Properties can carry logic now, invisibly to callers: $user->email = $input validates and normalizes, and the call site never knows. Inside a property's own hook, $this->email addresses the raw backing storage directly rather than re-triggering the hook — that's the defined mechanic, which is why the assignment in set isn't infinite recursion. A get-only hook that never touches its own name, like $displayName, is fully virtual: computed on read, no storage at all. Interfaces can even require properties now (public string $email { get; }) — genuinely new territory. Then the sibling feature — splitting who may read from who may write: class Order { public private(set) string $id; public protected(set) string $status = 'draft'; public function __construct(string $id) { $this->id = $id; } public function submit(): void { $this->status = 'submitted'; // inside: fine } } $order = new Order('ord_88'); echo $order->status; // read: public, works $order->status = 'paid'; // Error: set is protected public private(set) reads publicly, writes only within the class. This kills the last surviving getter pattern — "private field, public getter, mutation via methods" — while, unlike readonly, letting the class keep mutating its own state. You now have a clean trio to choose from per property: readonly — written once, ever; private(set) — written freely, but only by me; a set hook — writes run my logic first. Choose the weakest promise that's true. Traps: hooks and readonly are mutually exclusive — want restricted and validated, combine a hook with asymmetric visibility instead. Set-visibility can only be equal to or narrower than get; write-mostly inversions aren't a thing. Asymmetric visibility requires a typed property. And a judgment note with a blast radius: a get hook runs on every access, and no reader of $user->displayName can see that. Keep hooks cheap and side-effect-free — a hook that lazily fires a query turns innocent-looking property reads scattered through a template into a query storm nobody can find. Part Six: Things that simply didn't exist 19. Enums The feature your class-constant strings have been begging for since 2010. enum OrderStatus: string { case Draft = 'draft'; case Submitted = 'submitted'; case Paid = 'paid'; case Cancelled = 'cancelled'; public function isFinal(): bool { return match ($this) { self::Paid, self::Cancelled => true, self::Draft, self::Submitted => false, }; } } // From the database or a request — choose your failure mode deliberately: $status = OrderStatus::from($row['status']); // unknown value: throws ValueError $status = OrderStatus::tryFrom($input) ?? OrderStatus::Draft; // unknown value: graceful default if ($status === OrderStatus::Paid) { /* identity comparison — the whole point */ } $status->name; // 'Paid' — the case name $status->value; // 'paid' — the backing value; store THIS Each case is a singleton object — not a string constant you hope nobody typos, not a Ruby symbol that's really just a name, but a typed value that can only be one of the declared things. A function accepting OrderStatus cannot receive 'piad'. The : string makes it a backed enum, giving each case a serialization value for databases and JSON (json_encode emits the value; pure enums, which lack values, refuse to serialize — by design). At the storage boundary: write ->value out, revive with from() on the way in. Enums carry methods, constants, and interfaces — behavior lives with the type — and the match ($this) pattern above is where enums and match complete each other. Notice isFinal() lists every case and omits default on purpose: add a Refunded case next year and this method throws UnhandledMatchError the moment it's hit (and static analysis flags it before then), instead of silently misclassifying forever. What enums refuse to do is also the point: no mutable state, no instantiation — cases are shared singletons, which is precisely what makes === trustworthy. 20. Attributes #[Attribute(Attribute::TARGET_METHOD)] final class Route { public function __construct( public string $path, public string $method = 'GET', ) {} } final class UserController { #[Route('/users')] public function index(): Response { /* ... */ } #[Route('/users/{id}', method: 'DELETE')] public function destroy(int $id): Response { /* ... */ } } // The reading side — nothing scans these automatically: $ref = new ReflectionMethod(UserController::class, 'destroy'); foreach ($ref->getAttributes(Route::class) as $attr) { $route = $attr->newInstance(); // a real Route object, constructor-validated register($route->method, $route->path, /* ... */); } Structured metadata in the language itself, replacing the docblock-annotation cottage industry — the /** @Route("/users") */ comments that frameworks string-parsed by convention, where a typo was invisible and stripping comments could break production. An attribute is a class reference: autoloader-checked, constructor-typed, named-arguments-friendly, refactor-safe. Rename Route and every usage renames with it. The essential mental model: attributes are inert until something reflects on them. No global scanner exists; a framework — or your code, as above — asks via Reflection. An attribute nobody reads is pure decoration; the flip side is they cost nothing until read. You'll consume them before you author them: #[Override] makes the engine verify a method really overrides a parent (retiring the silent-typo-creates-new-method bug), and #[SensitiveParameter] redacts an argument from stack traces — put it on every $password you have, today. One constraint: attribute arguments must be constant expressions — literals, class constants, enum cases — no function calls, no new inside the #[...]. The engine room Two more from the list that deserve honest framing rather than a pretend tutorial, because you'll benefit from both while rarely writing either. Fibers are pausable full-stack call frames: $fiber = new Fiber(function (): void { $work = Fiber::suspend('ready'); // pause here, hand 'ready' out echo "Processing {$work}\n"; // continues when resumed }); $signal = $fiber->start(); // runs to the suspend; $signal === 'ready' $fiber->resume('batch-1'); // prints "Processing batch-1" The trick versus generators: a fiber can suspend from arbitrarily deep in a call stack without every intermediate function changing its return type — yield contaminated whole call chains with Generator, fibers don't. And it's cooperative concurrency, not parallelism: exactly one thing runs at a time, and a fiber pauses only where it says so. The honest career advice is that you'll probably never write new Fiber in application code — it exists so event-loop libraries (Revolt, Amp, ReactPHP) can offer async that reads synchronous. Know the primitive, recognize it in stack traces, reach for the libraries. The JIT is configuration, not code: opcache.enable=1 opcache.jit=tracing opcache.jit_buffer_size=128M Right-sized expectations: OPcache's bytecode caching (think APC, matured and built in) is what actually matters for web workloads — verify it's on and move on. The JIT compiles hot paths to machine code and shines where PHP is CPU-bound: math, image work, long-running CLI daemons. A typical database-bound request/response barely notices, because its time was never in the interpreter. Enable it, measure, don't expect miracles from CRUD. Marching orders Target a floor of the current-minus-one or minus-two release for the projects you're rehabbing — everything above lands there except the pipe operator and clone-with, the two newest, which want the current release your host may not run yet. Two tools turn the migration from archaeology into automation: Rector mechanically rewrites old code toward these features — it has literal rules for constructor promotion, arrow-function conversion, ?? adoption, and it will chew through a decade-old codebase while you drink coffee — and PHPStan (or Psalm) surfaces every place the new type system exposes what the old code was silently getting wrong. Run Rector first, PHPStan forever. And step back once before you dive in, because the twenty things above are secretly one thing. Types, readonly, enums, hooks, match exhaustiveness — every one of them is the language saying tell me what you intend, and I'll refuse to let the code drift from it. The defensive scaffolding filling your old projects — the isset walls, the is_string checks, the getters guarding nothing, the constants praying nobody typos them — existed because nothing enforced anything, so you enforced everything by hand. That job is over. You're not just catching up on syntax; you're about to delete the worst third of every file you open, and what remains will finally say what it means. Welcome back — the water's typed.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News