Repository: briannesbitt/Carbon (PHP)

Issue:
Globally set CarbonInterval::toStringFormat()
<!--
    🛑 DON'T REMOVE ME.
    This issue template apply to all
      - bug reports,
      - feature proposals,
      - and documentation requests

    Having all those informations will allow us to know exactly
    what you expect and answer you faster and precisely (answer
    that matches your Carbon version, PHP version and usage).
    
    Note: Comments between <!- - and - -> won't appear in the final
    issue (See [Preview] tab).
-->
Hello,

Is there a way to globally change the `CarbonInterval::__toString()` format? I'm using `CarbonInterval` in a generic class which type juggle to string when an attribute is `Stringable`.

Since [`PHP 8`](https://www.php.net/manual/en/language.oop5.magic.php#object.tostring), `CarbonInterval` is `Stringable`, so will be converted to string in my class, which currently defaults to `$this->forHuman()`. I would like to set globally something like:

```php
CarbonInterval::setToStringFormat(function ($interval) {
    return $interval->spec();
});

// OR

CarbonInterval::settings([
    'toStringFormat' => function ($interval) {
        return $interval->spec();
    }
]);
```

Is there a way to do that please?

Carbon version: **2.62.0**

PHP version: **8.1.0**

<!--
    Run the command `composer show nesbot/carbon`
    to get "versions"
    Use `echo phpversion();`
    to get PHP version.

    Some issues can depends on your context, settings,
    macros, timezone, language. So to be sure the code
    you give is enough to reproduce your bug, try it
    first in:
    https://try-carbon.herokuapp.com/?theme=xcode&export&embed

    You can use the [Options] button to change the version
    then when you get the bug with this editor, you can use
    the [Export] button, copy the link of the opened tab,
    then paste it in the issue. Then we can immediatly get
    your issue.
-->

Thanks!

@kylekatarnls edit: replacing `CarbonInterface` occurrences with `CarbonInterval` as it sounds it was the intended meaning.


Discussion:
Hello 👋 

We might implement a global `setToStringFormat` for consistency with `Carbon` date time class, but please keep in mind that it comes with some unexpected risks that are hard to anticipate.

For instance you might have sub-sub-dependency you're not even aware of that is casting `CarbonInterval` and relies on the forHumans() output to make some parsing, generating content for mail or whatever. If you set a global format for all `CarbonInterval`, it will apply to all casts to string in your own code base but also all the casts in your vendor code and you probably don't control 100% of that code, plus it might change when you update dependencies. And last the resulting bugs would not be easy to catch as they would probably not throw exceptions but more likely just cause unwanted behavior changes.

So even if we implement it, I recommend to stick with non-static settings() calls as much as you can so it's scoped to your source code and don't apply to vendor code.

Maybe you can:
```php
class IntervalService
{
    public function wrapInterval(mixed $value): CarbonInterval
    {
        if (!($value instanceof CarbonInterval)) {
            return $value;
        }

        return $value->settings([
            'toStringFormat' => static fn($interval) => $interval->spec(),
        ]);
    }

    public function __call(string $name, array $args)
    {
        return $this->wrapInterval(CarbonInterval::$name(...$args));
    }
}

$interval = new IntervalService();

echo $interval->fromString('2 days 5 hours'); // P1DT5H
echo $interval->wrapInterval(Carbon::parse('2022-08-27')->diffAsCarbonInterval('2022-08-29')); // P2D
```

This way instead of directly creating `CarbonInterval` objects from static constructors, you create them via your application factory (or wrap a result) which is then responsible for giving the settings they need for your app.

This is also more scalable as if you need at some point to extract part of your code to an other application, you won't have the global settings to be ported, or if you merge code from other place that would rely on a different `__toString` it won't collide.
@kylekatarnls I agree with you, thank you for pointing it out and thank you for the edit. Currently, what I could do (taking your comment into account) is:

```php
// Laravel Cast class
public function set($model, string $key, $value, array $attributes)
{
    CarbonInterval::setToStringFormat(function (CarbonInterval $interval) {
        return $interval->spec();
    });

    $casted = $value->toJson();

    CarbonInterval::setToStringFormat(null);

    return is_null($value) ? null : $value->toJson();
}
```

Also, I can `setToStringFormat()` on creation, because once the above `$value` exists, I can't know what it contains but the wrapper is a great idea, thank you
`->settings()` and `::setToStringFormat()` will work in 2.63.0 on CarbonInterval and CarbonPeriod as they do now for Carbon and CarbonImmutable.

You can test it using `composer require "nesbot/carbon:dev-master as 2.63.0"`



Patch:
diff --git a/src/Carbon/CarbonInterval.php b/src/Carbon/CarbonInterval.php
index 54c64d411a..ba89829b8a 100644
--- a/src/Carbon/CarbonInterval.php
+++ b/src/Carbon/CarbonInterval.php
@@ -24,6 +24,7 @@
 use Carbon\Traits\IntervalStep;
 use Carbon\Traits\Mixin;
 use Carbon\Traits\Options;
+use Carbon\Traits\ToStringFormat;
 use Closure;
 use DateInterval;
 use DateTimeInterface;
@@ -188,6 +189,7 @@ class CarbonInterval extends DateInterval implements CarbonConverterInterface
         Mixin::mixin as baseMixin;
     }
     use Options;
+    use ToStringFormat;
 
     /**
      * Interval spec period designators
@@ -1821,7 +1823,7 @@ public function forHumans($syntax = null, $short = false, $parts = -1, $options
      */
     public function __toString()
     {
-        $format = $this->localToStringFormat;
+        $format = $this->localToStringFormat ?? static::$toStringFormat;
 
         if (!$format) {
             return $this->forHumans();
diff --git a/src/Carbon/CarbonPeriod.php b/src/Carbon/CarbonPeriod.php
index e2be2cdae4..dbf5eb1b17 100644
--- a/src/Carbon/CarbonPeriod.php
+++ b/src/Carbon/CarbonPeriod.php
@@ -24,6 +24,7 @@
 use Carbon\Traits\IntervalRounding;
 use Carbon\Traits\Mixin;
 use Carbon\Traits\Options;
+use Carbon\Traits\ToStringFormat;
 use Closure;
 use Countable;
 use DateInterval;
@@ -175,6 +176,7 @@ class CarbonPeriod implements Iterator, Countable, JsonSerializable
         Mixin::mixin as baseMixin;
     }
     use Options;
+    use ToStringFormat;
 
     /**
      * Built-in filter for limit by recurrences.
@@ -1459,13 +1461,21 @@ public function toIso8601String()
      */
     public function toString()
     {
+        $format = $this->localToStringFormat ?? static::$toStringFormat;
+
+        if ($format instanceof Closure) {
+            return $format($this);
+        }
+
         $translator = ([$this->dateClass, 'getTranslator'])();
 
         $parts = [];
 
-        $format = !$this->startDate->isStartOfDay() || ($this->endDate && !$this->endDate->isStartOfDay())
-            ? 'Y-m-d H:i:s'
-            : 'Y-m-d';
+        $format = $format ?? (
+            !$this->startDate->isStartOfDay() || ($this->endDate && !$this->endDate->isStartOfDay())
+                ? 'Y-m-d H:i:s'
+                : 'Y-m-d'
+        );
 
         if ($this->recurrences !== null) {
             $parts[] = $this->translate('period_recurrences', [], $this->recurrences, $translator);
diff --git a/src/Carbon/Traits/Converter.php b/src/Carbon/Traits/Converter.php
index d6691b2ea5..05598d579e 100644
--- a/src/Carbon/Traits/Converter.php
+++ b/src/Carbon/Traits/Converter.php
@@ -34,39 +34,7 @@
  */
 trait Converter
 {
-    /**
-     * Format to use for __toString method when type juggling occurs.
-     *
-     * @var string|Closure|null
-     */
-    protected static $toStringFormat;
-
-    /**
-     * Reset the format used to the default when type juggling a Carbon instance to a string
-     *
-     * @return void
-     */
-    public static function resetToStringFormat()
-    {
-        static::setToStringFormat(null);
-    }
-
-    /**
-     * @deprecated To avoid conflict between different third-party libraries, static setters should not be used.
-     *             You should rather let Carbon object being casted to string with DEFAULT_TO_STRING_FORMAT, and
-     *             use other method or custom format passed to format() method if you need to dump an other string
-     *             format.
-     *
-     * Set the default format used when type juggling a Carbon instance to a string
-     *
-     * @param string|Closure|null $format
-     *
-     * @return void
-     */
-    public static function setToStringFormat($format)
-    {
-        static::$toStringFormat = $format;
-    }
+    use ToStringFormat;
 
     /**
      * Returns the formatted date string on success or FALSE on failure.
@@ -110,7 +78,7 @@ public function rawFormat($format)
      *
      * @example
      * ```
-     * echo Carbon::now(); // Carbon instances can be casted to string
+     * echo Carbon::now(); // Carbon instances can be cast to string
      * ```
      *
      * @return string
diff --git a/src/Carbon/Traits/ToStringFormat.php b/src/Carbon/Traits/ToStringFormat.php
new file mode 100644
index 0000000000..38cb240643
--- /dev/null
+++ b/src/Carbon/Traits/ToStringFormat.php
@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * This file is part of the Carbon package.
+ *
+ * (c) Brian Nesbitt <brian@nesbot.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Carbon\Traits;
+
+use Closure;
+
+/**
+ * Trait ToStringFormat.
+ *
+ * Handle global format customization for string cast of the object.
+ */
+trait ToStringFormat
+{
+    /**
+     * Format to use for __toString method when type juggling occurs.
+     *
+     * @var string|Closure|null
+     */
+    protected static $toStringFormat;
+
+    /**
+     * Reset the format used to the default when type juggling a Carbon instance to a string
+     *
+     * @return void
+     */
+    public static function resetToStringFormat()
+    {
+        static::setToStringFormat(null);
+    }
+
+    /**
+     * @param string|Closure|null $format
+     *
+     * @return void
+     *
+     * @deprecated To avoid conflict between different third-party libraries, static setters should not be used.
+     *             You should rather let Carbon object being cast to string with DEFAULT_TO_STRING_FORMAT, and
+     *             use other method or custom format passed to format() method if you need to dump another string
+     *             format.
+     *
+     * Set the default format used when type juggling a Carbon instance to a string.
+     */
+    public static function setToStringFormat($format)
+    {
+        static::$toStringFormat = $format;
+    }
+}


Test patch:
diff --git a/tests/CarbonInterval/ToStringTest.php b/tests/CarbonInterval/ToStringTest.php
index 25ebda721f..fb7af6338e 100644
--- a/tests/CarbonInterval/ToStringTest.php
+++ b/tests/CarbonInterval/ToStringTest.php
@@ -56,9 +56,14 @@ public function testOverrideWithInvert()
     public function testClosure()
     {
         $ci = CarbonInterval::create(11);
+        $this->assertSame('11 years:abc', $ci.':abc');
+        CarbonInterval::setToStringFormat('%Y');
+        $this->assertSame('11:abc', $ci.':abc');
         $ci->settings(['toStringFormat' => static function (CarbonInterval $interval) {
             return 'Y'.($interval->years * 2);
         }]);
         $this->assertSame('Y22:abc', $ci.':abc');
+
+        CarbonInterval::resetToStringFormat();
     }
 }
diff --git a/tests/CarbonPeriod/ToStringTest.php b/tests/CarbonPeriod/ToStringTest.php
index 5969752ea8..d0b60a1940 100644
--- a/tests/CarbonPeriod/ToStringTest.php
+++ b/tests/CarbonPeriod/ToStringTest.php
@@ -169,4 +169,34 @@ public function testStartOfWeekForPeriod()
             $period[0]->toImmutable()->startOfWeek()->format('Y-m-d H:i:s')
         );
     }
+
+    public function testToStringCustomization()
+    {
+        $sunday = CarbonImmutable::parse('2019-12-01');
+
+        $period = CarbonPeriod::create($sunday->startOfWeek(), '1 week', $sunday->endOfWeek());
+
+        $this->assertSame(
+            'Every 1 week from 2019-11-25 00:00:00 to 2019-12-01 23:59:59!!',
+            $period.'!!'
+        );
+
+        CarbonPeriod::setToStringFormat('m/d');
+
+        $this->assertSame(
+            'Every 1 week from 11/25 to 12/01!!',
+            $period.'!!'
+        );
+
+        $period->settings(['toStringFormat' => static function (CarbonPeriod $period) {
+            return $period->toIso8601String();
+        }]);
+
+        $this->assertSame(
+            '2019-11-25T00:00:00-05:00/P7D/2019-12-01T23:59:59-05:00!!',
+            $period.'!!'
+        );
+
+        CarbonPeriod::resetToStringFormat();
+    }
 }