dominik

joined 1 month ago
MODERATOR OF
7
submitted 3 days ago* (last edited 3 days ago) by [email protected] to c/[email protected]
 

The Problem

Every developer wants to use the latest and greatest features of their tools, and PHP is no exception. But sometimes you simply can’t upgrade—whether because of project constraints or because your users are still on an older PHP version. For instance, if you’re building a library, you’ll often need to target a version that’s a few releases behind the latest, so you’re not forcing your users to upgrade before they’re ready.

The Solution

Transpiling! Instead of writing code that only works on a modern PHP version, you write it using the newest features and then transpile it down to your target PHP version. One of the best tools for this job is Rector. You might know Rector as the tool that automatically upgrades your code to a newer version of PHP—but it works in reverse as well. Downgrading is just as easy. For example, to downgrade your code to PHP 7.4, your rector.php file can be as simple as this:

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
    ])
    ->withDowngradeSets(php74: true)
;

Now, simply run Rector as you normally would (for example, vendor/bin/rector process), and you’re all set..

As an example, here’s a class that uses many modern PHP features:

final readonly class ModernClass
{
    final protected const string TYPED_FINAL_CONSTANT = 'some-string';

    public function __construct(
        public int $promotedProperty,
        private stdClass $data = new stdClass(),
    ) {
        // new without parenthesis
        $selfName = new ReflectionClass($this)->getName();
        // named parameters and the new rounding mode enum
        $rounded = round(5.5, mode: RoundingMode::HalfTowardsZero);

        // previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances
        $array = [1, 2, 3];
        $count = iterator_count($array);
        $array = iterator_to_array($array);

        $callable = $this->methodThatReturnsNever(...);
        $callable();
    }

    private function methodThatReturnsNever(): never
    {
        throw new Exception();
    }

    // standalone false/true/null type
    public function returnTrue(): true
    {
        return true;
    }
    public function returnFalse(): false
    {
        return false;
    }
    public function returnNull(): null
    {
        return null;
    }
}

And here’s what it looks like after downgrading:

final class ModernClass
{
    /**
     * @readonly
     */
    public int $promotedProperty;
    /**
     * @readonly
     */
    private stdClass $data;
    /**
     * @var string
     */
    protected const TYPED_FINAL_CONSTANT = 'some-string';

    public function __construct(
        int $promotedProperty,
        ?stdClass $data = null
    ) {
        $data ??= new stdClass();
        $this->promotedProperty = $promotedProperty;
        $this->data = $data;
        // new without parenthesis
        $selfName = (new ReflectionClass($this))->getName();
        // named parameters and the new rounding mode enum
        $rounded = round(5.5, 0, \PHP_ROUND_HALF_DOWN);

        // previously those functions only worked with Traversable instances, in PHP 8.2 they work with both Traversable and array instances
        $array = [1, 2, 3];
        $count = iterator_count(is_array($array) ? new \ArrayIterator($array) : $array);
        $array = iterator_to_array(is_array($array) ? new \ArrayIterator($array) : $array);

        $callable = \Closure::fromCallable([$this, 'methodThatReturnsNever']);
        $callable();
    }

    /**
     * @return never
     */
    private function methodThatReturnsNever()
    {
        throw new Exception();
    }

    // standalone false/true/null type
    /**
     * @return true
     */
    public function returnTrue(): bool
    {
        return true;
    }
    /**
     * @return false
     */
    public function returnFalse(): bool
    {
        return false;
    }
    /**
     * @return null
     */
    public function returnNull()
    {
        return null;
    }
}

This is now a perfectly valid PHP 7.4 class. It’s amazing to see how much PHP has evolved since 7.4—not to mention compared to the old 5.x days. I personally can’t live without property promotion anymore.

Note: Not every piece of modern PHP code can be downgraded automatically. For example, Rector leaves the following property definitions unchanged:

    public bool $hooked {
        get => $this->hooked;
    }
    public private(set) bool $asymmetric = true;

I assume support for downgrading asymmetric visibility will eventually be added, but hooked properties are very hard to downgrade in general—even though in some specialized cases they could be converted to readonly properties.

Downgrading Your Composer Package

If you want to write your package using modern PHP features but still support older PHP versions, you need a way to let Composer know which version to install. One simple approach would be to publish a separate package for each PHP version—say, the main package as vendor/package and additional ones like vendor/package-82, vendor/package-74, etc. While this works, it has a drawback. For instance, if you’re on PHP 8.3 and later upgrade your main package to PHP 8.4, you’d have to force users to switch to a new package (say, vendor/package-83), rendering the package incompatible for anyone still on an older PHP version.

Instead, I leverage two behaviors of Composer:

  1. It always tries to install the newest version that matches your version constraints.
  2. It picks the latest version that is supported by the current environment.

This means you can add a suffix to each transpiled version. For version 1.2.0, you might have:

  • 1.2.084 (for PHP 8.4)
  • 1.2.083 (for PHP 8.3)
  • 1.2.082 (for PHP 8.2)
  • 1.2.081 (for PHP 8.1)
  • 1.2.080 (for PHP 8.0)
  • 1.2.074 (for PHP 7.4)

When someone runs composer require vendor/package, Composer will select the version with the highest version number that is compatible with their PHP runtime. So, a user on PHP 8.4 gets 1.2.084, while one on PHP 8.2 gets 1.2.082. If you use the caret (^) or greater-than-or-equal (>=) operator in your composer.json, you also future-proof your package: if someone with a hypothetical PHP 8.5 tries to install it, they’ll still get the highest compatible version (in this case, 1.2.084).

Of course, you’ll need to run the transpilation before each release and automatically update your composer.json file. For older PHP versions, you might also have to make additional adjustments. In one package I worked on, I had to include extra polyfills for PHP 7.2 and even downgrade PHPUnit—but overall, the process works really well.

You can see this approach in action in the Unleash PHP SDK. More specifically, check out this workflow file and, for example, this commit which shows all the changes involved when transpiling code from PHP 8.3 down to PHP 7.2.

Caveat: One important downside of this approach is that if a user installs the package in an environment that initially has a newer PHP version than the one where the code will eventually run (or where dependencies will be installed), Composer might install a version of the package that the actual runtime cannot handle.

I believe this approach offers the best of both worlds when writing packages. You get to enjoy all the modern PHP features (I can’t live without constructor property promotion, and public readonly properties are fantastic for writing simple DTOs), while still supporting users who aren’t able—or ready—to upgrade immediately.

It’s also a powerful tool if your development team can upgrade PHP versions faster than your server administrators. You can write your app using the latest syntax and features, and then transpile it to work on the servers that are actually in use.

So, what do you think? Is this an approach you or your team might consider?

[–] [email protected] 3 points 1 week ago

By the way, as mentioned in the post, if anyone can recommend a good algorithm to calculate Easter, let me know!

14
submitted 1 week ago* (last edited 6 days ago) by [email protected] to c/[email protected]
 

OpenSCAD is truly amazing in a way that no other 3D modeling software is, including those with limited scripting abilities.

You can implement standard algorithms from general-purpose languages, like the impressive Zeller's Congruence used to calculate the day of the week for any given date. I utilized this to make the calendar automatically adjust the date offset. Simply change the year number in the configurator, and the model remains accurate:

A calendar model screenshot for year 2025, showing that the Jan 1st is a Wednesday

According to my computer, Jan 1st, 2025, is indeed a Wednesday.

A screenshot of a 3D model showing that Jan 1st 2056 is a Saturday

A quick calendar check confirms that Jan 1st, 2056, is a Saturday!

Here’s the OpenSCAD function:

function getFirstDay(year, month, day = 1) =
    let (
        q = day,
        m = month < 3 ? month + 12 : month,
        adjusted_year = month < 3 ? year - 1 : year,
        K = (adjusted_year) % 100,
        J = floor((adjusted_year) / 100)
    )
    (
        let (
            h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7
        )
        ((h + 5) % 7) + 1
    );

I kept the variable names consistent with the Wikipedia page for easier verification.

Additionally, I included a generic leap year check and a function to get the correct number of days in a month:

function daysAmount(month) = month == 2
    ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28
    : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31));

Working with dates is always a “pleasure,” but doing so in a language with no built-in date support was especially interesting!

This project is highly user-friendly with multiple configurable options, including:

  • Selection of months to render, column layout, and layer height adjustments for multi-material printing.
  • Custom holiday markings, such as highlighting Saturdays in red and adding holidays through a comma-separated list.
  • Full translation support for titles, month names, and day names.
  • Configurable holes for magnets and screws to mount on fridges or walls.

Some options leverage libraries like JustinSDK/dotSCAD and davidson16807/relativity.scad lor string manipulation (e.g., replacing %year in the title with the selected year or splitting holiday dates).

The model is available on Makerworld. If it ever gets taken down (possibly due to my dissatisfaction with the recent Bambu firmware changes), here’s the full source code:

/**
 * MIT License
 *
 * Copyright (c) 2025 Dominik Chrástecký
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/* [What to render] */
// Whether to render the red parts (holidays, Sundays, Saturdays if enabled)
redParts = true;
// Whether to render the white parts (background)
whiteParts = true;
// Whether to render the black parts (dates, text)
blackParts = true;
// Whether to render the blue parts (background behind month names)
blueParts = true;

/* [General] */
// The year to generate the calendar for
year = 2024;
// The start month, useful if you want to print the calendar in multiple parts
startMonth = 1;
// The end month, useful if you want to print the calendar in multiple parts
endMonth = 12;
// comma separated holiday dates with day first and month second, for example: 1.1,8.5,5.7,6.7 (means Jan 1st, May 8th, Jul 5th, Jul 6th)
holidays = "";
// Whether you want to print using AMS, MMU or a similar system, or a single extruder version
multiMaterial = true;
// The height of the calendar
calendarHeight = 3.2;
// a number between 10 and 360, the higher the better quality
quality = 60; // [10:360]
// whether Saturdays should be rendered in red in addition to Sundays
saturdayRedColor = false;
// how many months to put on a single row
monthsPerRow = 3;

/* [Hook and magnet holes] */
// Enable hook holes?
hookHole = true;
// Enable magnet hole?
magnetHole = true;
// How much to add to the various sizes, if your printer is not well calibrated, you might need to make the tolerances larger
tolerances = 0.2;
// The diameter of the lower part of the hook hole
hookHoleDiameter = 5.6;
// The width of the upper part of the hook hole
hookHoleUpperPartWidth = 3;
// Whether the magnet is round or square
roundMagnet = true;
// The diameter of the magnet, ignored if the magnet is not round
magnetDiameter = 10;
// The width of the magnet, ignored if the magnet is round
magnetWidth = 10;
// The depth of the magnet, ignored if the magnet is round
magnetDepth = 10;
// The height of the magnet hole. Please make sure the calendarHeight is larger than the magnet hole, otherwise weird stuff might happen
magnetHeight = 2;
// When checked, the magnet hole will be hidden inside the calendar and you will have to pause the print to insert the magnet, if unchecked, the magnet hole will be visible on the back
hiddenMagnet = true;

/* [Text settings] */
// The name of the font to use
font = "Liberation Mono:style=Bold";
// The size of the month names
monthFontSize = 5.01;
// The size of the font for name days
dayNameFontSize = 2.51;
// The size of the font for calendar title
titleFontSize = 10.01;

/* [Calendar title] */
// The title of the calendar, %year will be replaced with the current year
calendarTitle = "Calendar %year";
// The space around the calendar title, make larger if your magnet is too big to fit
titleSpace = 15;

/* [Day names] */
// Your language version for Monday
monday = "MON";
// Your language version for Tuesday
tuesday = "TUE";
// Your language version for Wednesday
wednesday = "WED";
// Your language version for Thursday
thursday = "THU";
// Your language version for Friday
friday = "FRI";
// Your language version for Saturday
saturday = "SAT";
// Your language version for Sunday
sunday = "SUN";

/* [Month names] */
// Your language version for January
january = "JANUARY";
// Your language version for February
february = "FEBRUARY";
// Your language version for March
march = "MARCH";
// Your language version for April
april = "APRIL";
// Your language version for May
may = "MAY";
// Your language version for June
june = "JUNE";
// Your language version for July
july = "JULY";
// Your language version for August
august = "AUGUST";
// Your language version for September
september = "SEPTEMBER";
// Your language version for October
october = "OCTOBER";
// Your language version for November
november = "NOVEMBER";
// Your language version for December
december = "DECEMBER";

function getFirstDay(year, month, day = 1) =
    let (
        q = day,
        m = month < 3 ? month + 12 : month,
        adjusted_year = month < 3 ? year - 1 : year,
        K = (adjusted_year) % 100,
        J = floor((adjusted_year) / 100)
    )
    (
        let (
            h = (q + floor((13 * (m + 1)) / 5) + K + floor(K / 4) + floor(J / 4) + 5 * J) % 7
        )
        ((h + 5) % 7) + 1
    );

// from https://github.com/JustinSDK/dotSCAD/blob/master/src/util/_impl/_split_str_impl.scad
function sub_str(t, begin, end) =
    let(
        ed = is_undef(end) ? len(t) : end,
        cum = [
            for (i = begin, s = t[i], is_continue = i < ed;
            is_continue;
            i = i + 1, is_continue = i < ed, s = is_continue ? str(s, t[i]) : undef) s
        ]
    )
    cum[len(cum) - 1];

function _split_t_by(idxs, t) =
    let(leng = len(idxs))
    [sub_str(t, 0, idxs[0]), each [for (i = 0; i < leng; i = i + 1) sub_str(t, idxs[i] + 1, idxs[i + 1])]];

function daysAmount(month) = month == 2
    ? (year % 4 == 0 && (year % 400 == 0 || year % 100 != 0)) ? 29 : 28
    : (month % 2 == 0 ? (month >= 8 ? 31 : 30) : (month >= 8 ? 30 : 31));

function split_str(t, delimiter) = len(search(delimiter, t)) == 0 ? [t] : _split_t_by(search(delimiter, t, 0)[0], t);

function contains(value, array) =
    count_true([for (element = array) element == value]) > 0;

function count_true(values) =
    sum([for (v = values) v ? 1 : 0]);

function sum(values) =
    sum_helper(values, 0);

function sum_helper(values, i) =
    i < len(values) ? values[i] + sum_helper(values, i + 1) : 0;

// from https://github.com/davidson16807/relativity.scad/blob/master/strings.scad
function replace(string, replaced, replacement, ignore_case=false, regex=false) =
	_replace(string, replacement, index_of(string, replaced, ignore_case=ignore_case, regex=regex));

function _replace(string, replacement, indices, i=0) =
    i >= len(indices)?
        after(string, indices[len(indices)-1].y-1)
    : i == 0?
        str( before(string, indices[0].x), replacement, _replace(string, replacement, indices, i+1) )
    :
        str( between(string, indices[i-1].y, indices[i].x), replacement, _replace(string, replacement, indices, i+1) )
    ;

function after(string, index=0) =
	string == undef?
		undef
	: index == undef?
		undef
	: index < 0?
		string
	: index >= len(string)-1?
		""
	:
        join([for (i=[index+1:len(string)-1]) string[i]])
	;
function before(string, index=0) =
	string == undef?
		undef
	: index == undef?
		undef
	: index > len(string)?
		string
	: index <= 0?
		""
	:
        join([for (i=[0:index-1]) string[i]])
	;
function join(strings, delimeter="") =
	strings == undef?
		undef
	: strings == []?
		""
	: _join(strings, len(strings)-1, delimeter);
function _join(strings, index, delimeter) =
	index==0 ?
		strings[index]
	: str(_join(strings, index-1, delimeter), delimeter, strings[index]) ;

function index_of(string, pattern, ignore_case=false, regex=false) =
	_index_of(string,
        regex? _parse_rx(pattern) : pattern,
        regex=regex,
        ignore_case=ignore_case);
function _index_of(string, pattern, pos=0, regex=false, ignore_case=false) = 		//[start,end]
	pos == undef?
        undef
	: pos >= len(string)?
		[]
	:
        _index_of_recurse(string, pattern,
            _index_of_first(string, pattern, pos=pos, regex=regex, ignore_case=ignore_case),
            pos, regex, ignore_case)
	;

function _index_of_recurse(string, pattern, index_of_first, pos, regex, ignore_case) =
    index_of_first == undef?
        []
    : concat(
        [index_of_first],
        _coalesce_on(
            _index_of(string, pattern,
                    pos = index_of_first.y,
                    regex=regex,
                    ignore_case=ignore_case),
            undef,
            [])
    );
function _index_of_first(string, pattern, pos=0, ignore_case=false, regex=false) =
	pos == undef?
        undef
    : pos >= len(string)?
		undef
	: _coalesce_on([pos, _match(string, pattern, pos, regex=regex, ignore_case=ignore_case)],
		[pos, undef],
		_index_of_first(string, pattern, pos+1, regex=regex, ignore_case=ignore_case))
    ;

function _coalesce_on(value, error, fallback) =
	value == error?
		fallback
	:
		value
	;
function _match(string, pattern, pos, regex=false, ignore_case=false) =
    regex?
    	_match_parsed_peg(string, undef, pos, peg_op=pattern, ignore_case=ignore_case)[_POS]
    : starts_with(string, pattern, pos, ignore_case=ignore_case)?
        pos+len(pattern)
    :
        undef
    ;
function starts_with(string, start, pos=0, ignore_case=false, regex=false) =
	regex?
		_match_parsed_peg(string,
			undef,
			pos,
			_parse_rx(start),
			ignore_case=ignore_case) != undef
	:
		equals(	substring(string, pos, len(start)),
			start,
			ignore_case=ignore_case)
	;
function equals(this, that, ignore_case=false) =
	ignore_case?
		lower(this) == lower(that)
	:
		this==that
	;
function substring(string, start, length=undef) =
	length == undef?
		between(string, start, len(string))
	:
		between(string, start, length+start)
	;
function between(string, start, end) =
	string == undef?
		undef
	: start == undef?
		undef
	: start > len(string)?
		undef
	: start < 0?
		before(string, end)
	: end == undef?
		undef
	: end < 0?
		undef
	: end > len(string)?
		after(string, start-1)
	: start > end?
		undef
	: start == end ?
		""
	:
        join([for (i=[start:end-1]) string[i]])
	;

module _radiusCorner(depth, radius) {
    difference(){
       translate([radius / 2 + 0.1, radius / 2 + 0.1, 0]){
          cube([radius + 0.2, radius + 0.1, depth + 0.2], center=true);
       }

       cylinder(h = depth + 0.2, r = radius, center=true);
    }   
}

module roundedRectangle(width, height, depth, radius, leftTop = true, leftBottom = true, rightTop = true, rightBottom = true) {
    translate([width / 2, height / 2, depth / 2])
    difference() {
        cube([
            width,
            height,
            depth,
        ], center = true);
        if (rightTop) {
            translate([width / 2 - radius, height / 2 - radius]) {
                rotate(0) {
                    _radiusCorner(depth, radius);   
                }
            }
        }
        if (leftTop) {
            translate([-width / 2 + radius, height / 2 - radius]) {
                rotate(90) {
                    _radiusCorner(depth, radius);
                }
            }
        }
        if (leftBottom) {
            translate([-width / 2 + radius, -height / 2 + radius]) {
                rotate(180) {
                    _radiusCorner(depth, radius);
                }
            }
        }
        if (rightBottom) {            
            translate([width / 2 - radius, -height / 2 + radius]) {
                rotate(270) {
                    _radiusCorner(depth, radius);
                }
            }
        }
    }   
}

$fn = quality;

holidaysArray = split_str(holidays, ",");
hasHolidays = !(len(holidaysArray) == 1 && holidaysArray[0] == "");

plateWidth = 80;

colorWhite = "#ffffff";
colorBlue = "#2323F7";
colorBlack = "#000000";
colorRed = "#ff0000";

noMmuBlueOffset = 0.4;
noMmuBlackOffset = 0.8;
noMmuRedOffset = 1.2;
noMmuWhiteOffset = 1.6;

module monthBg(plateWidth, plateDepth, depth, margin) {
    height = 0.6;
    radius = 4;

    translate([
        margin,
        plateDepth - depth - 5,
        calendarHeight - height + 0.01
    ])
    roundedRectangle(
        plateWidth - margin * 2,
        depth,
        height + (multiMaterial ? 0 : noMmuBlueOffset),
        radius
    );
}

module monthName(month, plateWidth, plateDepth, bgDepth) {
    height = 0.6;

    monthNames = [january, february, march, april, may, june, july, august, september, october, november, december];

    color(colorWhite)
    translate([
        plateWidth / 2,
        plateDepth - bgDepth - 3,
        calendarHeight - height + 0.02
    ])
    linear_extrude(height + (multiMaterial ? 0 : noMmuWhiteOffset))
    text(monthNames[month - 1], size = monthFontSize, font = font, halign = "center");
}

module dayName(day, margin, plateWidth, plateDepth) {
    height = 0.6;
    days = [monday, tuesday, wednesday, thursday, friday, saturday, sunday];

    space = (plateWidth - margin * 2) / 7 + 0.4;

    translate([
        margin + (day - 1) * space,
        plateDepth - 20,
        calendarHeight - height + 0.01
    ])
    linear_extrude(height + (multiMaterial ? 0 : (day == 7 ? noMmuRedOffset : noMmuBlackOffset)))
    text(days[day - 1], size = dayNameFontSize, font = font);
}

module dayNumber(day, month, startOffset, plateWidth, plateDepth, margin) {
    height = 0.6;
    space = (plateWidth - margin * 2) / 7 + 0.4;

    index = (startOffset + day) % 7;
    stringDate = str(day, ".", month);

    isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray));

    translate([
        margin + ((startOffset + day - 1) % 7) * space,
        plateDepth - 25 - floor((startOffset + day - 1) / 7) * 5,
        calendarHeight - height + 0.01
    ])
    linear_extrude(height + (multiMaterial ? 0 : (isRed ? noMmuRedOffset : noMmuBlackOffset)))
    text(str(day), size = dayNameFontSize, font = font);
}

module monthPlate(year, month) {
    plateDepth = 55;
    monthBgDepth = 9;
    margin = 5;

    if (whiteParts) {
        difference() {
            color(colorWhite)
            cube([plateWidth, plateDepth, calendarHeight]);

            monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin);   

            for (day = [1:7]) {
                dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth);
            }

            for (day = [1:daysAmount(month)]) {
                startOffset = getFirstDay(year, month) - 1;
                dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth);
            }
        }

        monthName(month, plateWidth, plateDepth, monthBgDepth);
    }
    if (blueParts) {
        difference() {
            color(colorBlue)
            monthBg(plateWidth, plateDepth, monthBgDepth, margin = margin);
            monthName(month, plateWidth, plateDepth, monthBgDepth);
        }
    }

    for (day = [1:7]) {
        if (((day == 7 || day == 6 && saturdayRedColor) && redParts) || (!(day == 7 || day == 6 && saturdayRedColor) && blackParts)) {
            color(day == 7 || day == 6 && saturdayRedColor ? colorRed : colorBlack)
            dayName(day, margin = margin, plateWidth = plateWidth, plateDepth = plateDepth);
        }
    }

    for (day = [1:daysAmount(month)]) {
        startOffset = getFirstDay(year, month) - 1;
        index = (startOffset + day) % 7;

        stringDate = str(day, ".", month);
        isRed = index == 0 || saturdayRedColor && index == 6 || (hasHolidays && contains(stringDate, holidaysArray));

        if ((isRed && redParts) || (!isRed && blackParts)) {
            color(isRed ? colorRed : colorBlack)
            dayNumber(day, month, startOffset, plateWidth = plateWidth, margin = margin, plateDepth = plateDepth);
        }
    }
}

module title(bgHeight) {
    height = 0.6;

    translate([
        (plateWidth * monthsPerRow) / 2,
        bgHeight / 2,
        calendarHeight - height + 0.01
    ])
    linear_extrude(height + (multiMaterial ? 0 : noMmuBlackOffset))
    text(replace(calendarTitle, "%year", year), size = titleFontSize, halign = "center", valign = "center");
}

module hookHole() {
    height = calendarHeight + 1;
    translate([hookHoleDiameter / 2, hookHoleDiameter / 2, -0.01]) {
        translate([-hookHoleUpperPartWidth / 2, hookHoleDiameter / 5.6, 0])
        roundedRectangle(hookHoleUpperPartWidth + tolerances, 6, height, 1.5);
        cylinder(h = height, d = hookHoleDiameter + tolerances);        
    }
}

for (month = [startMonth:endMonth]) {
    translate([
        ((month - startMonth) % monthsPerRow) * plateWidth,
        -(ceil((month - startMonth + 1) / monthsPerRow)) * 55,
        0
    ])
    monthPlate(year, month);   
}

titleHeight = titleSpace;

if (whiteParts) {

    color(colorWhite)
    difference() {
        cube([plateWidth * monthsPerRow, titleHeight, calendarHeight]);
        title(titleHeight);

        if (hookHole) {
            margin = 10;

            translate([margin, 3])
            hookHole();

            translate([plateWidth * monthsPerRow - margin - hookHoleDiameter, 3])
            hookHole();
        }

        if (magnetHole) {
            translate([0, 0, hiddenMagnet ? 0.4 : 0]) {
                if (roundMagnet) {
                    translate([
                        (plateWidth * monthsPerRow) / 2,
                        magnetDiameter / 2 + 1,
                        -0.01
                    ])
                    cylinder(h = magnetHeight + tolerances, d = magnetDiameter + tolerances);
                } else {
                    translate([
                        (plateWidth * monthsPerRow) / 2 - magnetWidth / 2,
                        magnetDepth / 2,
                        -0.01
                    ])
                    cube([magnetWidth + tolerances, magnetDepth + tolerances, magnetHeight + tolerances]);
                }   
            }
        }
    }
}
if (blackParts) {
    color(colorBlack)
    title(titleHeight);
}

In a future update, I plan to implement an algorithm to calculate Easter, allowing it to be added to holidays with a single toggle. If you know of any algorithm that could be easily implemented in OpenSCAD, let me know!

[–] [email protected] 1 points 1 week ago* (last edited 1 week ago)

You mean the blog post I wrote myself and published on my ActivityPub enabled blog? That kind of "spam bot"?

18
submitted 1 week ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

Immutable systems offer many benefits—until you need to customize your filesystem by installing packages. While installing software isn’t difficult per se, SteamOS’s design means that most customizations are wiped during system upgrades. About a year ago, Valve added /nix to the list of directories that remain intact during updates, and that’s where Nix stores all of its packages.

If you’re not familiar with Nix: it’s a package manager that uses declarative definitions for your software instead of commands like apt install or dnf install. You simply list all your desired packages in a configuration file, and Nix takes care of installing them. Additionally, the handy nix-shell utility lets you spawn temporary shells with the packages you specify.

There are two primary ways to work with Nix comfortably: you can either run NixOS (which isn’t ideal on a Steam Deck) or use Home Manager.

Installing Nix

Switch to Desktop Mode and open Konsole for the following steps. First, install Nix itself using this command (see the official installation instructions):

sh <(curl -L https://nixos.org/nix/install) --no-daemon

This command installs Nix in single-user mode (--no-daemon), which is a good fit for SteamOS since it may not require sudo for most operations. (If it does ask for sudo, you’ll need to set up sudo on your Steam Deck.)

Next, load Nix into your current terminal session:

source .bash_profile

By default, Nix uses the unstable branch of packages. To switch to the stable channel, run:

nix-channel --add https://nixos.org/channels/nixos-24.11 nixpkgs

This command sets your nixpkgs channel to the latest stable version (in this example, 24.11). In the future, check the current stable version on the NixOS homepage.

Nix is now installed—but without Home Manager, it isn’t very user-friendly.

Installing Home Manager

First, add the Home Manager channel to your Nix configuration:

nix-channel --add https://github.com/nix-community/home-manager/archive/release-24.11.tar.gz home-manager

Note: Ensure that the version for both Nix and Home Manager match. In this example, both are 24.11.

If you prefer the unstable branch, you can instead run: nix-channel --add https://github.com/nix-community/home-manager/archive/master.tar.gz home-manager

Update your channels to include these changes:

nix-channel --update

Before proceeding, back up your Bash configuration files:

  • mv .bash_profile .bash_profile.bckp
  • mv .bashrc .bashrc.bckp

If you choose not to back them up, you’ll need to remove them because Home Manager creates these files during installation and will fail if they already exist.

Now, run the Home Manager installation:

nix-shell '<home-manager>' -A install

Once the installation completes, create your Home Manager configuration file using a text editor:

kate ~/.config/home-manager/home.nix

Paste in the following configuration:

{ config, pkgs, ... }:
{
  home.username = "deck";
  home.homeDirectory = "/home/deck";

  programs.bash = {
    enable = true;
    initExtra = ''
      if [ -e $HOME/.nix-profile/etc/profile.d/nix.sh ]; then . $HOME/.nix-profile/etc/profile.d/nix.sh; fi

      export NIX_SHELL_PRESERVE_PROMPT=1
      if [[ -n "$IN_NIX_SHELL" ]]; then
        export PS1="$PS1(nix-shell) "
      fi
    '';
  };

  home.stateVersion = "24.11"; # don't change this even if you upgrade your channel in the future, this should stay the same as the version you first installed nix on

  home.packages = with pkgs; [

  ];

  programs.home-manager.enable = true;
}

This configuration does the following:

  • Sets your username to deck (the default on Steam Deck).
  • Specifies the correct path to your home directory.
  • Enables Home Manager to manage your Bash shell and ensures the Nix environment is loaded automatically—so you won’t have to source it manually each time.
  • Adds a (nix-shell) suffix to your terminal prompt when you’re in a Nix shell, which is a subtle but useful improvement over the default behavior.
  • Defines the home.stateVersion, which should remain the same as when you first installed Nix (even if you later change your channels). You should never change it after the initial Nix installation
  • Enables Home Manager itself.
  • Provides an empty list (home.packages) where you can later add your desired packages.

Apply your new configuration by running:

home-manager switch

This is the basic workflow for managing your environment with Nix: update your configuration file and then run home-manager switch to apply the changes.

After closing and reopening your terminal, test the setup by running nix-shell. If you see an error indicating that default.nix is missing, everything is working as expected. (If the command isn’t found at all, something went wrong.)

Installing packages

To install packages, simply add them to the home.packages list in your configuration file. For example, to install nmap (for network scanning) and cowsay (because a cow makes everything better), update your configuration as follows:

  home.packages = with pkgs; [
      nmap
      cowsay
  ];

Keep the rest of the file unchanged, then apply the new configuration with home-manager switch. You can test the setup by running:

echo "Hello from my Steam Deck!" | cowsay

You should see this beauty in your terminal:

 ___________________________
< Hello from my Steam Deck! >
 ---------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Running nmap should display its usage instructions. If you decide to remove nmap (you're keeping cowsay, right?), just delete it from the configuration file and run home-manager switch again.

Tips

  • Create a desktop shortcut to your configuration file:

    • ln -s ~/.config/home-manager/home.nix ~/Desktop/Nix_Config
  • Run nix-collect-garbage periodically to remove unused packages and free up space.

  • Install the comma package. This nifty tool lets you run any package on the fly by simply prefixing the command with a comma.

    • For example, instead of adding nmap to your configuration, you could run , nmap to temporarily use it. (notice the comma in front of nmap)
  • Nix can do much more than just manage packages—for instance, you can use it to create environment variables, shell aliases, systemd services, files, and more.

Cover image sources: Wikimedia Commons, NixOS

9
Lazy objects in PHP 8.4 (chrastecky.dev)
submitted 2 weeks ago* (last edited 5 days ago) by [email protected] to c/[email protected]
 

Lazy objects allow you to delay initialization until it’s absolutely necessary. This is particularly useful when an object depends on I/O operations—such as accessing a database or making an external HTTP request. Although you could previously implement lazy loading in userland, there were significant caveats. For example, you couldn’t declare the proxied class as final, because the lazy proxy must extend it to satisfy type checks. If you’ve ever used Doctrine, you might have noticed that entities cannot be declared final for precisely this reason.

Without further ado, let's dive right in!

Lazy deserializer

For this project, I created a simple DTO:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
    ) {
    }
}

Notice that the class is declared as both final and readonly—something that wouldn’t have been possible with a pure userland implementation. Here’s what the deserializer looks like:

final readonly class LazyDeserializer
{
    /**
     * @template T of object
     * @param class-string<T> $class
     * @return T
     */
    public function deserialize(array $data, string $class): object
    {
        // todo
    }
}

This setup lets us write code like the following:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object);

Implementing the deserializer

I split the implementation into multiple methods for better maintainability. Let’s start with the single public method whose signature we just saw:

    /**
     * @template T of object
     * @param class-string<T> $class
     * @return T
     */
    public function deserialize(array $data, string $class): object
    {
        $reflection = new ReflectionClass($class);

        return $reflection->newLazyGhost(function (object $object) use ($data): void {
            $this->deserializeObject($data, $object);
        });
    }

First, we obtain a reflection of the target class and then call its newLazyGhost method. The lazy ghost is responsible for creating the lazily initialized object. It accepts a single callback that receives an instance of the target object (which remains uninitialized) and uses it to set up the properties in the deserializeObject method.

At this point, the method returns an object of the target class (specified by the $class parameter) with all its properties uninitialized. These properties will be initialized only when you access them. For example, if you var_dump the resulting object right now, you might see something like:

lazy ghost object(App\Dto\Product)#7 (0) {
  ["name"]=>
  uninitialized(string)
  ["description"]=>
  uninitialized(string)
  ["price"]=>
  uninitialized(float)
}

Notice that it doesn’t matter that the private deserializeObject method isn’t implemented yet—the object remains truly lazy. Any errors related to initialization will only appear when you try to access one of its uninitialized properties.

Here's an implementation of the private method:

    private function deserializeObject(array $data, object $object): void
    {
        $reflection = new ReflectionObject($object);

        foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
            if (!isset($data[$property->getName()])) {
                if ($property->getType()?->allowsNull()) {
                    $property->setValue($object, null);
                }
                continue;
            }

            $property->setValue($object, $data[$property->getName()]);
            unset($data[$property->getName()]);
        }

        if (count($data)) {
            throw new LogicException('There are left-over data in the array which could not be deserialized into any property.');
        }
    }

I’m using reflection here because the object is marked as readonly—this is the only way to set a readonly property outside the constructor. If the properties weren’t readonly, you could simply assign values directly (e.g. $object->$propertyName = $value).

The process is straightforward: we iterate over each public property of the class, assign the corresponding value from the data array, and if a property is missing (and its type allows null), we set it to null. Finally, we ensure there’s no leftover data, which would indicate a mismatch between the data and the model. (Note that this is a naive implementation; real-world deserializers tend to be more robust.)

Now, let’s modify the previous example slightly to trigger the initialization of the model:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object); // this will print the uninitialized model

$object->name; // simply calling a property will force the object to initialize

var_dump($object); // this now prints:

// object(App\Dto\Product)#7 (3) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//}

Note that this implementation isn’t very useful on its own since it merely assigns properties from a static array—there’s no I/O involved. Let’s enhance it to support deserializing more complex values, such as enums, nested objects, and (most importantly) I/O-bound entities (which we’ll simulate with an HTTP request). First, instead of directly assigning the value, I add another private method:

$property->setValue($object, $this->assignValue($property, $data[$property->getName()]));

Now, let’s implement assignValue:

    private function assignValue(ReflectionProperty $property, mixed $value): mixed
    {
        $type = $property->getType();
        if (!$type) {
            return $value;
        }
        if ($value === null && $type->allowsNull()) {
            return null;
        }
        if (!$type instanceof ReflectionNamedType) {
            throw new LogicException('Only a single type is allowed');
        }

        $typeName = $type->getName();
        if (is_a($typeName, BackedEnum::class, true)) {
            return $typeName::from($value);
        } else if (is_array($value) && class_exists($typeName)) {
            return $this->deserialize($value, $typeName);
        } else if ($this->isHttpEntity($typeName) && is_string($value)) {
            return $this->fetchHttpEntity($typeName, $value);
        }

        return $value;
    }

Here’s what happens in assignValue:

  • If the property has no type, the value is returned as is.
  • If the value is null and the type is nullable, null is returned.
  • An exception is thrown if the type isn’t a single named type (supporting multiple types would add too much complexity for this example).
  • Three cases are then handled:
    • If the type is a backed enum, we convert the value using its built-in from method.
    • If the value is an array and the type corresponds to an existing class, we recursively call deserialize to support nested objects.
    • If the type is marked as a HTTP entity (using the HttpEntity attribute) and the value is a string, we assume it represents an ID and fetch the entity.

Here are some more objects that the deserializer now supports:

enum Availability: int
{
    case InStock = 1;
    case OnTheWay = 2;
    case OutOfStock = 3;
}

final readonly class ProductVariant
{
    public function __construct(
        public string $color,
        public string $size,
    ) {
    }
}

#[HttpEntity]
final readonly class Seller
{
    public function __construct(
        public string $id,
        public string $name,
        public float $rating,
    ) {
    }
}

For completeness, here’s the definition of the HttpEntity attribute and a helper method to check for it:

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HttpEntity
{
}

private function isHttpEntity(string $typeName): bool
{
    if (!class_exists($typeName)) {
        return false;
    }

    $reflection = new ReflectionClass($typeName);
    $attributes = $reflection->getAttributes(HttpEntity::class);

    return count($attributes) > 0;
}

The enum and the non-HTTP entity class work out of the box. For example:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
        public Availability $availability,
        public ?ProductVariant $variant = null,
    ) {
    }
}

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
    'availability' => 2,
    'variant' => [
        'color' => 'golden',
        'size' => '3',
    ],
];

$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);

var_dump($object);

// lazy ghost object(App\Dto\Product)#7 (0) {
//  ["name"]=>
//  uninitialized(string)
//  ["description"]=>
//  uninitialized(string)
//  ["price"]=>
//  uninitialized(float)
//  ["availability"]=>
//  uninitialized(App\Enum\Availability)
//  ["variant"]=>
//  uninitialized(?App\Dto\ProductVariant)
//}

$object->name;

var_dump($object);

// object(App\Dto\Product)#7 (5) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//  ["availability"]=>
//  enum(App\Enum\Availability::OnTheWay)
//  ["variant"]=>
//  lazy ghost object(App\Dto\ProductVariant)#19 (0) {
//    ["color"]=>
//    uninitialized(string)
//    ["size"]=>
//    uninitialized(string)
//  }
//}

$object->variant->color;

// object(App\Dto\Product)#7 (5) {
//  ["name"]=>
//  string(9) "Door knob"
//  ["description"]=>
//  string(39) "The coolest door knob you've ever seen!"
//  ["price"]=>
//  float(123.45)
//  ["availability"]=>
//  enum(App\Enum\Availability::OnTheWay)
//  ["variant"]=>
//  object(App\Dto\ProductVariant)#19 (2) {
//    ["color"]=>
//    string(6) "golden"
//    ["size"]=>
//    string(1) "3"
//  }
//}

Notice that the variant property is also lazily initialized—which is pretty neat. Every nested object is handled lazily.

I/O bound entities

Now, let’s move on to HTTP entities. We’ll create a service that “fetches” them (in this case, we’ll simulate the fetch):

final readonly class HttpEntityFetcher
{
    public function fetchRawByIdAndType(string $id, string $type): ?array
    {
        sleep(1);
        return [
            'id' => $id,
            'name' => 'Cool seller',
            'rating' => 4.9,
        ];
    }
}

Here, I simulate a slow HTTP request that takes one second to complete and returns JSON data (already decoded into an array). Note that for this example the fetch always returns a seller.

Now all that’s missing is the LazyDeserializer::fetchHttpEntity() method:

public function __construct(
    private HttpEntityFetcher $entityFetcher,
) {
}

/**
 * @template T of object
 *
 * @param class-string<T> $typeName
 * @return T|null
 */
private function fetchHttpEntity(string $typeName, string $id): ?object
{
    return new ReflectionClass($typeName)->newLazyGhost(function (object $object) use ($typeName, $id): void {
        $data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
        if (!is_array($data)) {
            throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
        }

        $this->deserializeObject($data, $object);
    });
}

This lazy ghost postpones the HTTP request until one of the object’s properties is actually accessed. Next, let’s add the seller property to our product:

final readonly class Product
{
    public function __construct(
        public string $name,
        public string $description,
        public float $price,
        public Availability $availability,
        public Seller $seller,
        public ?ProductVariant $variant = null,
    ) {
    }
}

And here’s an example that adds some timing measurements to our deserialization:

$data = [
    'name' => 'Door knob',
    'description' => "The coolest door knob you've ever seen!",
    'price' => 123.45,
    'availability' => 2,
    'variant' => [
        'color' => 'golden',
        'size' => '3',
    ],
    'seller' => 'some-seller-id',
];

$deserializer = new LazyDeserializer(new HttpEntityFetcher());
$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);

echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;

$start = microtime(true);
$object->seller->name;
$end = microtime(true);

echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;

On my PC, this prints:

Deserializer took: 0.0000250340 seconds
Fetching seller name took: 1.0002360344 seconds

The deserialization is nearly instantaneous—the delay comes when the HTTP request is eventually executed during initialization.

Partially initializing ghost objects

In the example above, there’s one piece of information we already know about the seller even before any HTTP request is made: its ID. Triggering a network call just to obtain the ID is unnecessary. Fortunately, we can initialize that property immediately:

/**
 * @template T of object
 *
 * @param class-string<T> $typeName
 * @return T|null
 */
private function fetchHttpEntity(string $typeName, string $id): ?object
{
    $reflection = new ReflectionClass($typeName);
    $entity = $reflection->newLazyGhost(function (object $object) use ($typeName, $id): void {
        $data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
        if (!is_array($data)) {
            throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
        }

        unset($data['id']);
        $this->deserializeObject($data, $object);
    });
    $reflection->getProperty('id')->setRawValueWithoutLazyInitialization($entity, $id);

    return $entity;
}

The setRawValueWithoutLazyInitialization method (a catchy name, right?) lets you assign a value to a property without forcing the rest of the object to be initialized.

$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);

echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

$start = microtime(true);
$object->seller->id;
$end = microtime(true);

echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

$start = microtime(true);
$object->seller->name;
$end = microtime(true);

echo "Fetching seller name took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);

This prints timings similar to:

Deserializer took: 0.0000338554 seconds
Fetching seller id took: 0.0000009537 seconds
Fetching seller name took: 1.0001599789 seconds

As you can see, accessing the ID is immediate, while accessing another property (like the name) triggers the full initialization.

lazy ghost object(App\Entity\Seller)#20 (1) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  uninitialized(string)
  ["rating"]=>
  uninitialized(float)
}

lazy ghost object(App\Entity\Seller)#20 (1) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  uninitialized(string)
  ["rating"]=>
  uninitialized(float)
}

object(App\Entity\Seller)#20 (3) {
  ["id"]=>
  string(14) "some-seller-id"
  ["name"]=>
  string(11) "Cool seller"
  ["rating"]=>
  float(4.9)
}

That’s it for the deserializer example! It’s a simplified implementation, but I imagine that Doctrine may eventually replace its userland proxy approach with these core lazy objects once they target PHP 8.4 and later.

Private key generating example

As a bonus, here’s an additional example—a private key generator that I’ve actually used in one of my libraries. (View on GitHub)

public function generate(int $bits = 4096): KeyPair
{
    $reflection = new ReflectionClass(KeyPair::class);
    $keyPair = $reflection->newLazyGhost(function (KeyPair $keyPair) use ($bits) {
        $config = [
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
            'private_key_bits' => $bits,
        ];
        $resource = openssl_pkey_new($config) ?: throw new CryptographyException('Failed generating new private key');

        $privateKeyPem = '';
        openssl_pkey_export($resource, $privateKeyPem);
        assert(is_string($privateKeyPem));

        $details = openssl_pkey_get_details($resource) ?: throw new CryptographyException('Failed decoding the private key');
        $publicKeyPem = $details['key'];
        assert(is_string($publicKeyPem));

        $reflection = new ReflectionObject($keyPair);
        $reflection->getProperty('privateKey')->setValue($privateKeyPem);
        $reflection->getProperty('publicKey')->setValue($publicKeyPem);

        return $keyPair;
    });
    assert($keyPair instanceof KeyPair);

    return $keyPair;
}

This postpones the expensive operation (generating a 4096 bits private key) until it's actually needed.

5
submitted 3 weeks ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

The problem

When you have a <ng-template> that accepts parameters via context, you usually lose TypeScript's type safety, reverting to the prehistoric age of JavaScript with no type enforcement:

<ng-template #someTemplate let-someVariable="someVariable">
  {{Math.abs(someVariable)}} <!-- compiler and IDE have no idea that the variable is a string -->
</ng-template>

With this approach, you can perform any operation on someVariable, and the compiler won't warn you—even if it results in runtime errors.

The solution

To ensure type safety, we can create a type assertion guard directive:

@Directive({
  selector: 'ng-template[some-template]',
  standalone: true,
})
export class SomeTemplateNgTemplate {
  static ngTemplateContextGuard(
    directive: SomeTemplateNgTemplate,
    context: unknown
  ): context is {someVariable: string} {
    return true;
  }
}

Explanation

  1. Directive setup

    • This directive applies to <ng-template> elements that include the some-template attribute (ng-template[some-template] in the selector).
    • It's marked as standalone, which is the recommended approach in modern Angular.
  2. Type Context Guard

    • The class name is not important and can be anything.

    • The static ngTemplateContextGuard function is where the magic happens.

    • It must accept two parameters:

      • An instance of itself (directive: SomeTemplateNgTemplate).
      • The context (which is typed as unknown which is a more type-safe any).
    • The return type uses a TypeScript type predicate, which tells the compiler: If this function returns true, then the context must match the given type { someVariable: string }.

Since this function always returns true, TypeScript will assume that every template using this directive has the expected type.

Important note: As with all TypeScript type assertions, this is a compile-time safety measure—it does not enforce types at runtime. You can still pass invalid values, but TypeScript will warn you beforehand.

Applying the Directive

Now, update your template to use the directive:

<ng-template some-template #someTemplate let-someVariable="someVariable">
  {{Math.abs(someVariable)}}
</ng-template>

The result

With the some-template directive in place, Angular now correctly infers the type of someVariable. If you try to use Math.abs(someVariable), TypeScript will now show an error:

NG5: Argument of type 'string' is not assignable to parameter of type 'number'.

Conclusion

By leveraging ngTemplateContextGuard, you can enforce strong typing within ng-template contexts, making your Angular code safer and more maintainable. This simple trick helps catch potential errors at compile time rather than at runtime—ensuring better developer experience and fewer unexpected bugs.

6
submitted 4 weeks ago* (last edited 1 week ago) by [email protected] to c/[email protected]
 

If you're unsure where you could (or why you should) use feature flags in your project, this section is for you, otherwise feel free to skip this part.

What are feature flags

Feature flags are runtime switches that enable or disable specific code paths dynamically. You might already be using them without realizing it! If your system allows enabling or disabling functionality via database settings (e.g., toggling registrations, comments, or user uploads), you're already using a basic form of feature flags. But these self-built options are rarely as thought-out as dedicated feature flagging systems.

Dedicated feature flagging systems

Dedicated feature flagging systems provide a standardized way to manage feature toggles and unlock additional use cases, such as:

  • Gradually roll out features to a subset of users, such as internal users or beta testers.

    • Makes it possible to do a gradual rollout to test out the reactions without deploying a feature to everyone
    • Enable features based on the region of the user (like GDPR, CCPA)
  • Create experimental features without maintaining separate branches

  • A/B test multiple versions of a new feature

  • Implement a kill switch to turn off some parts of the code in case of emergency (attack, data corruption...)

  • Replace your built-in permission system

  • Create toggleable features that are only needed in certain cases (for example, enable a high verbosity logging if you run into issues)

  • Rollback features if they're broken

  • and many more

Unleash

Disclaimer: I originally wrote the open-source Unleash PHP SDK, which was later adopted as the official Unleash SDK. While I’m paid to maintain it, this article is not sponsored (and I'm not an employee of Unleash). I’m writing it for the same reasons I originally created the SDK: I love how Unleash is implemented and think more people should use it!

Unleash is one such system. Unleash offers both a paid plan and a self-hosted open-source version. While the open-source version lacks some premium features, since the release of the constraints feature to the OSS version it's feature-complete for my needs.

What makes Unleash unique is the way the feature evaluation is handled: everything happens locally, meaning your app does not leak any data to Unleash. Your application also avoids performance overhead from unnecessary HTTP requests. Usually these systems do the evaluation on the server and just return a yes/no response. With Unleash, you instead get the whole configuration as a simple JSON and the SDK does evaluation locally (to the point that you could even use the SDK without Unleash at all, you can simply provide a static JSON). Furthermore, the features are cached locally for half a minute or so, thus the only I/O overhead Unleash adds is 2 http requests a minute. And another cool feature is that they support pretty much every major programming language. Now that my fanboying is over, let's go over Unleash in PHP!

Unleash in PHP

Installing the SDK is straightforward, simply run composer require unleash/client. The documentation can be found at Packagist or GitHub. It supports PHP versions as old as 7.2. Afterwards you create an instance of the Unleash object that you will use throughout your code:

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->build();

The app name and instance ID are used to identify clients. The app URL is the Unleash server endpoint, which you can find in the settings page.

Once you've set up the Unleash object, using it is extremely simple:

if ($unleash->isEnabled('new-product-page')) {
  // do one thing
} else if ($unleash->isEnabled('semi-new-product-page')) {
  // do other thing
} else {
  // do yet another thing
}

If you do A/B testing, you can configure variants like this:

$topMenuVariant = $unleash->getVariant('top-menu');
if (!$topMenuVariant->isEnabled()) {
  // todo the user does not have access to the feature at all
} else {
  $payload = $topMenuVariant->getPayload();
  // let's assume the payload is a JSON
  assert($payload->getType() === VariantPayloadType::JSON);
  $payloadData = $payload->fromJson();

  // todo display the menu based on the received payload
}

Configuring the features

All of the above must be configured somewhere and that place is the Unleash UI. You can test out their official demo (just put whatever email in there, it doesn't even have to be real, there's no confirmation) if you don't want to install Unleash locally.

Each feature has multiple environments, by default a development and production one (I think in the open source version you cannot create more, though I successfully did so by fiddling directly with the database) and each environment must have one or more strategies (unless the environment is disabled). Strategies is what controls whether the feature is enabled for a user or not. I'll go briefly over the simple strategies and then write a bit more about the complex ones (and custom ones).

  1. Standard - simple yes/no strategy, no configuration, just enabled or disabled
  2. User IDs - enable the feature for specific user IDs
  3. IPs and Hosts - enable the feature for specific IP addresses and hostnames respectively

Unleash doesn’t automatically know your app’s user IDs—you need to provide them via an Unleash context:

$context = new UnleashContext(currentUserId: '123');

if ($unleash->isEnabled('some-feature', $context)) {
  // todo
}

Or more likely, if you don't want to pass around a manually created context all the time, just create a provider that will create the default context:

final class MyContextProvider implements UnleashContextProvider
{
    public function getContext(): Context
    {
        $context = new UnleashContext();
        $context->setCurrentUserId('user id from my app');

        return $context;     
    }
}

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->withContextProvider(new MyContextProvider())
    ->build();

if ($unleash->isEnabled('some-feature')) {
  // todo
}

The Gradual rollout strategy

This powerful strategy allows you to roll out features to a percentage of users based on a chosen context field (e.g., user ID, IP address, or any custom attribute). With the help of constraints you can configure very complex access scenarios thanks to the many operators that are available (various string, array, date, numeric and version operators) for each of your context fields. So in short, you create arbitrary fields in your context which you can then validate with any of the supported operators.

This is sort of becoming the catch-all default strategy because it can do everything the others can with the help of constraints. If you want to emulate the Standard strategy, just make it always available to 100% of your users. Emulating User IDs strategy can be done by having it available to 100% of your userbase and adding a constraint that the userId must be one of the specified values. And so on.

Custom strategies

Need even more flexibility? You can create custom strategies! Here’s a real-world example from one of my projects:

<?php

namespace App\Service\Unleash;

use InvalidArgumentException;
use Unleash\Client\Configuration\Context;
use Unleash\Client\DTO\Strategy;
use Unleash\Client\Strategy\AbstractStrategyHandler;
use Override;

final class AccountIdUnleashStrategy extends AbstractStrategyHandler
{
    public const string CONTEXT_NAME = 'currentAccountId';

    #[Override]
    public function getStrategyName(): string
    {
        return 'accountId';
    }

    #[Override]
    public function isEnabled(Strategy $strategy, Context $context): bool
    {
        $allowedAccountIds = $this->findParameter('accountIds', $strategy);
        if (!$allowedAccountIds) {
            return false;
        }

        try {
            $currentCompanyAccountId = $context->getCustomProperty(self::CONTEXT_NAME);
        } catch (InvalidArgumentException) {
            return false;
        }

        $allowedAccountIds = array_map('trim', explode(',', $allowedAccountIds));
        $enabled = in_array($currentCompanyAccountId, $allowedAccountIds, true);

        if (!$enabled) {
            return false;
        }

        return $this->validateConstraints($strategy, $context);
    }
}

Then simply register it:

$unleash = UnleashBuilder::create()
    ->withAppName('Some app name')
    ->withAppUrl('https://my-unleash-server.com/api/')
    ->withInstanceId('Some instance id')
    ->withContextProvider(new MyContextProvider())
    ->withStrategy(new AccountIdUnleashStrategy())
    ->build();

The strategy is then simply created in Unleash where you add an accountIds field of type list and mark it as required. Note that this strategy could also be defined using a Gradual rollout strategy with constraints, but I think having a custom one like that provides a better developer experience.

One downside to custom strategies is that if you use them in different projects, you need to create them in each project and the behavior must be the same (meaning the same context fields and the same implementation even across languages).

Unleash in Symfony

The Unleash Symfony bundle handles most of the configuration for you and offers additional features, such as:

  • #[IsEnabled] attribute for controller routes
  • Automatic user ID if the Symfony Security component is configured
  • Automatic integration with the Symfony http request object, like fetching the remote IP from it instead of from the $_SERVER array
  • Automatic environment context value based on the kernel environment
  • Custom context properties configured either as static values, as Expression Language expressions or provided via an event listener
  • Twig functions, tags, tests and filters
  • Automatically registered custom strategies, you simply implement them and Unleash knows about them
  • and more

Additional notes

There are many other Unleash features I haven’t covered, such as the frontend proxy (which handles evaluation and prevents client-side state leakage). Some advanced features are better suited for official documentation rather than a blog post.

 

The problem

If you use a SQLite database in a Doctrine project and enable foreign key checks, you’ll run into an issue with table-modifying migrations: You often need to drop and fully recreate the table. If that table is referenced by others, the migration will fail unless you disable the foreign key checks. Furthermore, the entire migration runs inside a transaction, and SQLite doesn’t allow changing foreign key checks during a transaction.

The solution

There are several possible solutions, but here’s a particularly neat one made possible by PHP 8.4’s new property hooks:

final class VersionXXXXXXXXXXXXXX extends AbstractMigration
{
    protected $connection {
        get {
            $this->connection->executeStatement('PRAGMA foreign_keys = OFF');
            return $this->connection;
        }
        set => $this->connection = $value;
    }

    public function up(Schema $schema): void
    {
        // TODO create migration
    }

    public function down(Schema $schema): void
    {
        // TODO create migration
    }
}

The code above overrides the $connection property from the parent class with a property hook, so every time the migration system requests a connection, the foreign key checks are disabled.